Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Implement email verification #7161

Merged
merged 39 commits into from
Jul 25, 2023
Merged
Show file tree
Hide file tree
Changes from 10 commits
Commits
Show all changes
39 commits
Select commit Hold shift + click to select a range
158c1d6
Implement email verification
frcroth Jun 19, 2023
cc05620
Mark keys as already used
frcroth Jun 20, 2023
28e1fc3
Insert full link into mail
frcroth Jun 20, 2023
fd24ca1
Make schemas match
frcroth Jun 20, 2023
719a714
Update csv
frcroth Jun 20, 2023
61247e9
Update changelog and migrations
frcroth Jun 20, 2023
6e8685a
Make linkExpiry optional and actually check it
frcroth Jun 20, 2023
1c09a45
Merge branch 'master' into verify-emails
frcroth Jun 20, 2023
027ee0c
implement view in frontend which triggers the email verification and …
philippotto Jun 22, 2023
a6ab850
redirect to dashboard after clicking verification link, change expiry…
philippotto Jun 22, 2023
1644a6a
Improve flow
frcroth Jun 26, 2023
a58a16e
Fix message
frcroth Jun 26, 2023
0cc544d
Increment evolution number
frcroth Jun 26, 2023
46b5c4e
Merge branch 'master' into verify-emails
frcroth Jun 26, 2023
217ab54
tell user that they need to log in when they try to resend a verifica…
philippotto Jun 27, 2023
7660d83
Expose email verification status via API
frcroth Jun 30, 2023
053e97f
Merge branch 'master' into verify-emails
frcroth Jun 30, 2023
5288370
warn user when email is not verified yet
philippotto Jun 30, 2023
94abd13
perform warning when user is set in store so that registrations are a…
philippotto Jun 30, 2023
6319aab
add missing module
philippotto Jun 30, 2023
2375940
refresh e2e snapshots
fm3 Jul 4, 2023
94be7ec
Merge branch 'master' into verify-emails
frcroth Jul 4, 2023
b5b3439
Apply suggestions from code review
frcroth Jul 4, 2023
ac50601
updated verify email template
hotzenklotz Jul 11, 2023
e303949
Give correct link expiry information in email
frcroth Jul 18, 2023
9aee53c
Merge branch 'master' into verify-emails
frcroth Jul 18, 2023
dbfc79e
Only invalidate email verification when email is actually updated; in…
frcroth Jul 18, 2023
3bf1a1f
immediately close potential unverified-email warning when verificatio…
philippotto Jul 18, 2023
89075bd
Merge branch 'verify-emails' of github.com:scalableminds/webknossos i…
philippotto Jul 18, 2023
0c5de7c
Lint
frcroth Jul 18, 2023
94f144e
refresh snapshots
fm3 Jul 20, 2023
fac75ff
Increment evolution number
frcroth Jul 24, 2023
2016cfa
Merge branch 'master' into verify-emails
frcroth Jul 24, 2023
5351b5c
Merge branch 'master' into verify-emails
fm3 Jul 24, 2023
30cff28
Remove email logging
frcroth Jul 25, 2023
ffbfb53
Merge branch 'master' into verify-emails
frcroth Jul 25, 2023
e758ea2
Merge branch 'master' into verify-emails
frcroth Jul 25, 2023
1e28e99
Merge branch 'master' into verify-emails
frcroth Jul 25, 2023
2439d7f
Update migration to set all emails as verified
frcroth Jul 25, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
- Added ability to view [zarr v3](https://zarr-specs.readthedocs.io/en/latest/v3/core/v3.0.html) datasets. [#7079](https://github.com/scalableminds/webknossos/pull/7079)
- Added an index structure for volume annotation segments, in preparation for per-segment statistics. [#7063](https://github.com/scalableminds/webknossos/pull/7063)
- Instead of showing all possible action items next to each other, there is now an overflow menu for layer actions. [#7123](https://github.com/scalableminds/webknossos/pull/7123)
- 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
- Creating bounding boxes can now be done by dragging the left mouse button (when the bounding box tool is selected). To move around in the dataset while this tool is active, keep ALT pressed. [#7118](https://github.com/scalableminds/webknossos/pull/7118)
Expand Down
1 change: 1 addition & 0 deletions MIGRATIONS.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,3 +11,4 @@ User-facing changes are documented in the [changelog](CHANGELOG.released.md).

### Postgres Evolutions:
- [102-no-more-wkconnect.sql](conf/evolutions/102-no-more-wkconnect.sql)
- [103-verify-email.sql](conf/evolutions/103-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 @@ -127,7 +128,8 @@ class AuthenticationController @Inject()(
autoActivate: Boolean,
password: Option[String],
inviteBox: Box[Invite] = Empty,
registerBrainDB: Boolean = false)(implicit request: Request[AnyContent]): Fox[User] = {
registerBrainDB: Boolean = false,
verifyEmail: Boolean = false)(implicit request: Request[AnyContent]): Fox[User] = {
frcroth marked this conversation as resolved.
Show resolved Hide resolved
val passwordInfo: PasswordInfo =
password.map(passwordHasher.hash).getOrElse(userService.getOpenIdConnectPasswordInfo)
for {
Expand All @@ -138,7 +140,8 @@ class AuthenticationController @Inject()(
autoActivate,
passwordInfo,
isAdmin = false,
isOrganizationOwner = false) ?~> "user.creation.failed"
isOrganizationOwner = false,
emailVerified = verifyEmail) ?~> "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 @@ -175,6 +178,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.assertUserHasVerifiedEmail(user)(GlobalAccessContext, ec))
_ <- multiUserDAO.updateLastLoggedInIdentity(user._multiUser, user._id)(GlobalAccessContext)
_ = userDAO.updateLastActivity(user._id)(GlobalAccessContext)
} yield result
Expand Down Expand Up @@ -515,7 +520,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 @@ -525,7 +530,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,
verifyEmail = 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 @@ -577,14 +588,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,
emailVerified = false
) ?~> "user.creation.failed"
_ = analyticsService.track(SignupEvent(user, hadInvite = false))
multiUser <- multiUserDAO.findOne(user._multiUser)
dataStoreToken <- bearerTokenAuthenticatorService
Expand Down
34 changes: 34 additions & 0 deletions app/controllers/EmailVerificationController.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
package controllers

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

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

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

@ApiOperation(value = "")
frcroth marked this conversation as resolved.
Show resolved Hide resolved
def verify(key: String): Action[AnyContent] = Action.async { implicit request =>
for {
_ <- emailVerificationService.verify(key)(GlobalAccessContext, ec)
frcroth marked this conversation as resolved.
Show resolved Hide resolved
} yield Ok
}

@ApiOperation(value = "")
def requestVerificationMail: Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
_ <- emailVerificationService.sendEmailVerification(request.identity)
} yield Ok
}
}
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 ()

}
61 changes: 61 additions & 0 deletions app/models/user/EmailVerificationService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
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 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) {

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

def sendEmailVerification(user: User)(implicit ctx: DBAccessContext, ec: ExecutionContext): Fox[Unit] =
for {
multiUser <- multiUserDAO.findOne(user._multiUser)(ctx)
key: String <- Fox.successful(RandomIDGenerator.generateBlocking(32))
frcroth marked this conversation as resolved.
Show resolved Hide resolved
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"
_ = Mailer ! Send(defaultMails.emailVerificationMail(user, multiUser.email, fullVerificationLink))
} yield ()

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

def assertUserHasVerifiedEmail(user: User)(
implicit ctx: DBAccessContext,
ec: ExecutionContext
): Fox[Unit] =
for {
multiUser: MultiUser <- multiUserDAO.findOne(user._multiUser) ?~> "user.notFound"
endOfGracePeriod: Instant = multiUser.created + conf.WebKnossos.User.EmailVerification.gracePeriod
overGracePeriod = endOfGracePeriod.isPast
_ <- Fox.bool2Fox(
!conf.WebKnossos.User.EmailVerification.required || multiUser.isEmailVerified || !overGracePeriod) ?~> "user.email.notVerified"
} yield ()
}
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
8 changes: 6 additions & 2 deletions app/models/user/UserService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,7 @@ class UserService @Inject()(conf: WkConf,
teamMembershipService: TeamMembershipService,
dataSetDAO: DataSetDAO,
tokenDAO: TokenDAO,
emailVerificationService: EmailVerificationService,
defaultMails: DefaultMails,
actorSystem: ActorSystem)(implicit ec: ExecutionContext)
extends FoxImplicits
Expand Down Expand Up @@ -91,7 +92,8 @@ class UserService @Inject()(conf: WkConf,
isActive: Boolean,
passwordInfo: PasswordInfo,
isAdmin: Boolean,
isOrganizationOwner: Boolean): Fox[User] = {
isOrganizationOwner: Boolean,
emailVerified: Boolean): Fox[User] = {
implicit val ctx: GlobalAccessContext.type = GlobalAccessContext
for {
_ <- Fox.assertTrue(multiUserDAO.emailNotPresentYet(email)(GlobalAccessContext)) ?~> "user.email.alreadyInUse"
Expand All @@ -100,7 +102,8 @@ class UserService @Inject()(conf: WkConf,
multiUserId,
email,
passwordInfo,
isSuperUser = false
isSuperUser = false,
isEmailVerified = emailVerified
)
_ <- multiUserDAO.insertOne(multiUser)
organizationTeamId <- organizationDAO.findOrganizationTeamId(organizationId)
Expand All @@ -122,6 +125,7 @@ class UserService @Inject()(conf: WkConf,
isUnlisted = false,
lastTaskTypeId = None
)
_ <- Fox.runIf(!emailVerified)(emailVerificationService.sendEmailVerification(user))
_ <- userDAO.insertOne(user)
_ <- Fox.combined(teamMemberships.map(userDAO.insertTeamMembership(user._id, _)))
} yield user
Expand Down
8 changes: 8 additions & 0 deletions app/oxalis/mail/DefaultMails.scala
Original file line number Diff line number Diff line change
Expand Up @@ -204,4 +204,12 @@ class DefaultMails @Inject()(conf: WkConf) {
recipients = List(userEmail)
)

def emailVerificationMail(user: User, userEmail: String, key: String): Mail =
Mail(
from = defaultSender,
subject = "Verify Your Email at WEBKNOSSOS",
bodyHtml = html.mail.verifyEmail(user.name, key).body,
recipients = List(userEmail)
)

}
9 changes: 9 additions & 0 deletions app/utils/WkConf.scala
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,15 @@ class WkConf @Inject()(configuration: Configuration) extends ConfigReader with L
val timeTrackingPause: FiniteDuration = get[FiniteDuration]("webKnossos.user.timeTrackingPause")
val inviteExpiry: FiniteDuration = get[FiniteDuration]("webKnossos.user.inviteExpiry")
val ssoKey: String = get[String]("webKnossos.user.ssoKey")

object EmailVerification {
val activated: Boolean = get[Boolean]("webKnossos.user.emailVerification.activated")
val required: Boolean = get[Boolean]("webKnossos.user.emailVerification.required")
val gracePeriod: FiniteDuration = get[FiniteDuration]("webKnossos.user.emailVerification.gracePeriod")
val linkExpiry: Option[FiniteDuration] =
getOptional[FiniteDuration]("webKnossos.user.emailVerification.linkExpiry")
}

}

val newOrganizationMailingList: String = get[String]("webKnossos.newOrganizationMailingList")
Expand Down
16 changes: 16 additions & 0 deletions app/views/mail/verifyEmail.scala.html
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
@( name: String, url: String )

@emailBaseTemplate() {
<p>Hi @name,</p>

<p>
Please use the link below to verify your email.
</p>
<p>
<a href="@{url}">@url</a>.
</p>


<p>With best regards,<br />your WEBKNOSSOS team</p>
}

Loading