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

Add OIDC authentication #6534

Merged
merged 51 commits into from
Dec 5, 2022
Merged
Show file tree
Hide file tree
Changes from 49 commits
Commits
Show all changes
51 commits
Select commit Hold shift + click to select a range
94bf748
Update silhouette dependency
frcroth Sep 28, 2022
48a9daa
fix legacy route compilation
fm3 Sep 29, 2022
5c8df51
Merge branch 'master' into sso-auth
fm3 Sep 29, 2022
93c15a5
upgrade to play 2.8.16
fm3 Sep 29, 2022
1357814
undo comment out in routes file
fm3 Sep 29, 2022
2701c8e
Add oidc routes
frcroth Oct 5, 2022
d987909
Fix typo
frcroth Oct 5, 2022
6401562
Update schema for oidc
frcroth Oct 7, 2022
efca4e3
Create or sign up user after OIDC authentication
frcroth Oct 7, 2022
d6326af
Sign in when registering via OIDC
frcroth Oct 9, 2022
676a0df
Improve usage of fox
frcroth Oct 19, 2022
b0f0f8d
Refactoring
frcroth Oct 20, 2022
504a9c4
Add OIDC client implementation
frcroth Oct 20, 2022
c625086
Merge branch 'master' into oidc
frcroth Oct 20, 2022
f56cd5b
Fix cannot alter enum in transaction block
frcroth Oct 20, 2022
49d3a8f
Fix warnings and complie errors
frcroth Oct 20, 2022
4873fba
Drop view to change column type
frcroth Oct 20, 2022
31b6998
Fix evolution
frcroth Oct 20, 2022
4676667
Remove complicated reversion
frcroth Oct 20, 2022
0025553
Update migrations and changelog
frcroth Oct 20, 2022
0edb066
Merge branch 'master' into oidc
frcroth Oct 20, 2022
f67770c
Renaming
frcroth Oct 25, 2022
5109ea7
Check for validity of OIDC config
frcroth Oct 26, 2022
edef763
Rename and move
frcroth Oct 26, 2022
07277c1
WIP: Extract user creation
frcroth Oct 26, 2022
47361cc
Move oidc config to OpenIdConnectClient.scala
frcroth Oct 26, 2022
1e447cf
Merge branch 'master' into oidc
frcroth Oct 26, 2022
b767c8d
Fix application.conf
frcroth Oct 26, 2022
e7ac9af
Add notice on route usage
frcroth Oct 26, 2022
fd8cbea
Fix lint error
frcroth Oct 27, 2022
a889f7a
Refactoring
frcroth Oct 30, 2022
395b133
Merge branch 'master' into oidc
frcroth Oct 30, 2022
633fab1
Remove redirect for frontend integration
frcroth Nov 2, 2022
308ff63
provide login-via-sso button in log in form
philippotto Nov 18, 2022
f7ad5e6
Merge branch 'master' into oidc
frcroth Nov 20, 2022
f6e7c5b
Fix config values
frcroth Nov 20, 2022
221fd64
Add feature flag for oidc
frcroth Nov 20, 2022
4ace8f1
Check if oidc is enabled in OIDC client
frcroth Nov 22, 2022
5e46a3d
Update end to end tests
frcroth Nov 22, 2022
13dc1ff
hide sso-button if oidc is disabled
philippotto Nov 23, 2022
390647c
Merge branch 'master' into oidc
frcroth Nov 24, 2022
90de845
Make methods private
frcroth Nov 24, 2022
d94f43f
Rename feature flag
frcroth Nov 24, 2022
caae3ce
Update e2e tests
frcroth Nov 24, 2022
d200266
Validate token with public key if available
frcroth Nov 25, 2022
0694935
Fix warning
frcroth Nov 30, 2022
d851569
Merge branch 'master' into oidc, Update Evolution number
frcroth Nov 30, 2022
c2d6f37
Merge branch 'master' into oidc
philippotto Dec 1, 2022
f119cf0
rename oidcEnabled to openIdConnectEnabled
philippotto Dec 1, 2022
0bbfd99
Fix signup redirect failing
frcroth Dec 2, 2022
3a5f1d5
Merge branch 'master' into oidc
frcroth Dec 5, 2022
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 @@ -11,6 +11,7 @@ For upgrade instructions, please check the [migration guide](MIGRATIONS.released
[Commits](https://github.com/scalableminds/webknossos/compare/22.12.0...HEAD)

### Added
- Added sign in via OIDC. [#6534](https://github.com/scalableminds/webknossos/pull/6534)
- Added a new datasets tab to the dashboard which supports managing datasets in folders. Folders can be organized hierarchically and datasets can be moved into these folders. Selecting a dataset will show dataset details in a sidebar. [#6591](https://github.com/scalableminds/webknossos/pull/6591)

### Changed
Expand Down
2 changes: 2 additions & 0 deletions MIGRATIONS.unreleased.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ User-facing changes are documented in the [changelog](CHANGELOG.released.md).
- Bulk task creation now needs the taskTypeId, the task type summary will no longer be accepted. If you have scripts generating CSVs for bulk task creation, they should not output task type summaries. [#6640](https://github.com/scalableminds/webknossos/pull/6640)

### Postgres Evolutions:

- [091-folders.sql](conf/evolutions/091-folders.sql)
- [092-oidc.sql](conf/evolutions/092-oidc.sql)
122 changes: 99 additions & 23 deletions app/controllers/AuthenticationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import akka.actor.ActorSystem
import com.mohiva.play.silhouette.api.actions.SecuredRequest
import com.mohiva.play.silhouette.api.exceptions.ProviderException
import com.mohiva.play.silhouette.api.services.AuthenticatorResult
import com.mohiva.play.silhouette.api.util.Credentials
import com.mohiva.play.silhouette.api.util.{Credentials, PasswordInfo}
import com.mohiva.play.silhouette.api.{LoginInfo, Silhouette}
import com.mohiva.play.silhouette.impl.providers.CredentialsProvider
import com.scalableminds.util.accesscontext.{AuthorizedAccessContext, DBAccessContext, GlobalAccessContext}
import com.scalableminds.util.tools.JsonHelper.validateJsValue
import com.scalableminds.util.tools.{Fox, FoxImplicits, TextUtils}
import models.analytics.{AnalyticsService, InviteEvent, JoinOrganizationEvent, SignupEvent}
import models.annotation.AnnotationState.Cancelled
Expand All @@ -27,7 +28,7 @@ import play.api.data.Forms.{email, _}
import play.api.data.validation.Constraints._
import play.api.i18n.Messages
import play.api.libs.json._
import play.api.mvc.{Action, AnyContent, PlayBodyParsers}
import play.api.mvc.{Action, AnyContent, Cookie, PlayBodyParsers, Request, Result}
import utils.{ObjectId, WkConf}

import java.net.URLEncoder
Expand Down Expand Up @@ -55,6 +56,7 @@ class AuthenticationController @Inject()(
annotationDAO: AnnotationDAO,
voxelyticsDAO: VoxelyticsDAO,
wkSilhouetteEnvironment: WkSilhouetteEnvironment,
openIdConnectClient: OpenIdConnectClient,
sil: Silhouette[WkEnv])(implicit ec: ExecutionContext, bodyParsers: PlayBodyParsers)
extends Controller
with AuthForms
Expand Down Expand Up @@ -83,7 +85,7 @@ class AuthenticationController @Inject()(
errors ::= Messages("user.lastName.invalid")
""
}
multiUserDAO.findOneByEmail(email)(GlobalAccessContext).toFox.futureBox.flatMap {
multiUserDAO.findOneByEmail(email)(GlobalAccessContext).futureBox.flatMap {
case Full(_) =>
errors ::= Messages("user.email.alreadyInUse")
Fox.successful(BadRequest(Json.obj("messages" -> Json.toJson(errors.map(t => Json.obj("error" -> t))))))
Expand All @@ -98,25 +100,15 @@ class AuthenticationController @Inject()(
inviteBox.toOption,
organizationName)(GlobalAccessContext) ?~> Messages("organization.notFound", signUpData.organization)
autoActivate = inviteBox.toOption.map(_.autoActivate).getOrElse(organization.enableAutoVerify)
user <- userService.insert(organization._id,
email,
firstName,
lastName,
autoActivate,
passwordHasher.hash(signUpData.password)) ?~> "user.creation.failed"
multiUser <- multiUserDAO.findOne(user._multiUser)(GlobalAccessContext)
_ = analyticsService.track(SignupEvent(user, inviteBox.isDefined))
_ <- Fox.runOptional(inviteBox.toOption)(i =>
inviteService.deactivateUsedInvite(i)(GlobalAccessContext))
brainDBResult <- brainTracing.registerIfNeeded(user, signUpData.password).toFox
_ <- createUser(organization,
email,
firstName,
lastName,
autoActivate,
Option(signUpData.password),
inviteBox,
registerBrainDB = true)
} yield {
if (conf.Features.isDemoInstance) {
mailchimpClient.registerUser(user, multiUser, tag = MailchimpTag.RegisteredAsUser)
} else {
Mailer ! Send(defaultMails.newUserMail(user.name, email, brainDBResult, autoActivate))
}
Mailer ! Send(
defaultMails.registerAdminNotifyerMail(user.name, email, brainDBResult, organization, autoActivate))
Ok
}
}
Expand All @@ -126,6 +118,38 @@ class AuthenticationController @Inject()(
)
}

private def createUser(organization: Organization,
email: String,
firstName: String,
lastName: String,
autoActivate: Boolean,
password: Option[String],
inviteBox: Box[Invite] = Empty,
registerBrainDB: Boolean = false)(implicit request: Request[AnyContent]): Fox[User] = {
val passwordInfo: PasswordInfo =
password.map(passwordHasher.hash).getOrElse(userService.getOpenIdConnectPasswordInfo)
for {
user <- userService.insert(organization._id, email, firstName, lastName, autoActivate, passwordInfo) ?~> "user.creation.failed"
multiUser <- multiUserDAO.findOne(user._multiUser)(GlobalAccessContext)
_ = analyticsService.track(SignupEvent(user, inviteBox.isDefined))
_ <- Fox.runIf(inviteBox.isDefined)(Fox.runOptional(inviteBox.toOption)(i =>
inviteService.deactivateUsedInvite(i)(GlobalAccessContext)))
brainDBResult <- Fox
.runIf(registerBrainDB)(brainTracing.registerIfNeeded(user, password.getOrElse("")).toFox)
.getOrElse(None)
.toFox
_ = if (conf.Features.isDemoInstance) {
mailchimpClient.registerUser(user, multiUser, tag = MailchimpTag.RegisteredAsUser)
} else {
Mailer ! Send(defaultMails.newUserMail(user.name, email, brainDBResult, autoActivate))
}
_ = Mailer ! Send(
defaultMails.registerAdminNotifyerMail(user.name, email, brainDBResult, organization, autoActivate))
} yield {
user
}
}

def authenticate: Action[AnyContent] = Action.async { implicit request =>
signInForm.bindFromRequest.fold(
bogusForm => Future.successful(BadRequest(bogusForm.toString)),
Expand Down Expand Up @@ -430,7 +454,7 @@ class AuthenticationController @Inject()(
request.identity match {
case Some(user) =>
// logged in
// Check if the request we recieved was signed using our private sso-key
// Check if the request we received was signed using our private sso-key
if (shaHex(ssoKey, sso) == sig) {
val payload = new String(Base64.decodeBase64(sso))
val values = play.core.parsers.FormUrlEncodedParser.parse(payload)
Expand All @@ -457,6 +481,58 @@ class AuthenticationController @Inject()(
}
}

lazy val absoluteOpenIdConnectCallbackURL = s"${conf.Http.uri}/api/auth/oidc/callback"

def loginViaOpenIdConnect(): Action[AnyContent] = sil.UserAwareAction.async { implicit request =>
openIdConnectClient.getRedirectUrl(absoluteOpenIdConnectCallbackURL).map(url => Ok(Json.obj("redirect_url" -> url)))
}

private def loginUser(loginInfo: LoginInfo)(implicit request: Request[AnyContent]): Future[Result] =
userService.retrieve(loginInfo).flatMap {
case Some(user) if !user.isDeactivated =>
for {
authenticator: CombinedAuthenticator <- combinedAuthenticatorService.create(loginInfo)
value: Cookie <- combinedAuthenticatorService.init(authenticator)
result: AuthenticatorResult <- combinedAuthenticatorService.embed(value, Redirect("/dashboard"))
_ <- multiUserDAO.updateLastLoggedInIdentity(user._multiUser, user._id)(GlobalAccessContext)
_ = userDAO.updateLastActivity(user._id)(GlobalAccessContext)
} yield result
case None =>
Future.successful(BadRequest(Messages("error.noUser")))
case Some(_) => Future.successful(BadRequest(Messages("user.deactivated")))
}

// Is called after user was successfully authenticated
def loginOrSignupViaOidc(oidc: OpenIdConnectClaimSet): Request[AnyContent] => Future[Result] = {
implicit request: Request[AnyContent] =>
userService.userFromMultiUserEmail(oidc.email)(GlobalAccessContext).futureBox.flatMap {
case Full(user) =>
val loginInfo = LoginInfo("credentials", user._id.toString)
loginUser(loginInfo)
case Empty =>
for {
organization: Organization <- organizationService.findOneByInviteByNameOrDefault(None, None)(
GlobalAccessContext)
user <- createUser(organization, oidc.email, oidc.given_name, oidc.family_name, autoActivate = true, None)
// After registering, also login
loginInfo = LoginInfo("credentials", user._id.toString)
loginResult <- loginUser(loginInfo)
} yield loginResult
case _ => Future.successful(InternalServerError)
}
}

def openIdCallback(): Action[AnyContent] = Action.async { implicit request =>
for {
code <- openIdConnectClient.getToken(
absoluteOpenIdConnectCallbackURL,
request.queryString.get("code").flatMap(_.headOption).getOrElse("missing code"),
)
oidc: OpenIdConnectClaimSet <- validateJsValue[OpenIdConnectClaimSet](code).toFox
user_result <- loginOrSignupViaOidc(oidc)(request)
} yield user_result
}

private def shaHex(key: String, valueToDigest: String): String =
new HmacUtils(HmacAlgorithms.HMAC_SHA_256, key).hmacHex(valueToDigest)

Expand All @@ -476,7 +552,7 @@ class AuthenticationController @Inject()(
errors ::= Messages("user.lastName.invalid")
""
}
multiUserDAO.findOneByEmail(email)(GlobalAccessContext).toFox.futureBox.flatMap {
multiUserDAO.findOneByEmail(email)(GlobalAccessContext).futureBox.flatMap {
case Full(_) =>
errors ::= Messages("user.email.alreadyInUse")
Fox.successful(BadRequest(Json.obj("messages" -> Json.toJson(errors.map(t => Json.obj("error" -> t))))))
Expand Down
2 changes: 1 addition & 1 deletion app/controllers/TaskController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ Expects:
file.filename.toLowerCase.endsWith(".nml") || file.filename.toLowerCase.endsWith(".zip"))
_ <- bool2Fox(inputFiles.nonEmpty) ?~> "nml.file.notFound"
jsonString <- body.dataParts.get("formJSON").flatMap(_.headOption) ?~> "format.json.missing"
params <- JsonHelper.parseJsonToFox[NmlTaskParameters](jsonString) ?~> "task.create.failed"
params <- JsonHelper.parseAndValidateJson[NmlTaskParameters](jsonString) ?~> "task.create.failed"
_ <- taskCreationService.assertBatchLimit(inputFiles.length, List(params.taskTypeId))
taskTypeIdValidated <- ObjectId.fromString(params.taskTypeId) ?~> "taskType.id.invalid"
taskType <- taskTypeDAO.findOne(taskTypeIdValidated) ?~> "taskType.notFound" ~> NOT_FOUND
Expand Down
2 changes: 1 addition & 1 deletion app/models/annotation/Annotation.scala
Original file line number Diff line number Diff line change
Expand Up @@ -151,7 +151,7 @@ class AnnotationDAO @Inject()(sqlClient: SQLClient, annotationLayerDAO: Annotati
for {
state <- AnnotationState.fromString(r.state).toFox
typ <- AnnotationType.fromString(r.typ).toFox
viewconfigurationOpt <- Fox.runOptional(r.viewconfiguration)(JsonHelper.parseJsonToFox[JsObject](_))
viewconfigurationOpt <- Fox.runOptional(r.viewconfiguration)(JsonHelper.parseAndValidateJson[JsObject](_))
visibility <- AnnotationVisibility.fromString(r.visibility).toFox
annotationLayers <- annotationLayerDAO.findAnnotationLayersFor(ObjectId(r._Id))
} yield {
Expand Down
10 changes: 5 additions & 5 deletions app/models/binary/DataSet.scala
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,10 @@ class DataSetDAO @Inject()(sqlClient: SQLClient,
for {
scale <- parseScaleOpt(r.scale)
defaultViewConfigurationOpt <- Fox.runOptional(r.defaultviewconfiguration)(
JsonHelper.parseJsonToFox[DataSetViewConfiguration](_))
JsonHelper.parseAndValidateJson[DataSetViewConfiguration](_))
adminViewConfigurationOpt <- Fox.runOptional(r.adminviewconfiguration)(
JsonHelper.parseJsonToFox[DataSetViewConfiguration](_))
details <- Fox.runOptional(r.details)(JsonHelper.parseJsonToFox[JsObject](_))
JsonHelper.parseAndValidateJson[DataSetViewConfiguration](_))
details <- Fox.runOptional(r.details)(JsonHelper.parseAndValidateJson[JsObject](_))
} yield {
DataSet(
ObjectId(r._Id),
Expand Down Expand Up @@ -452,9 +452,9 @@ class DataSetDataLayerDAO @Inject()(sqlClient: SQLClient, dataSetResolutionsDAO:
resolutions <- Fox.fillOption(standinResolutions)(
dataSetResolutionsDAO.findDataResolutionForLayer(dataSetId, row.name) ?~> "Could not find resolution for layer")
defaultViewConfigurationOpt <- Fox.runOptional(row.defaultviewconfiguration)(
JsonHelper.parseJsonToFox[LayerViewConfiguration](_))
JsonHelper.parseAndValidateJson[LayerViewConfiguration](_))
adminViewConfigurationOpt <- Fox.runOptional(row.adminviewconfiguration)(
JsonHelper.parseJsonToFox[LayerViewConfiguration](_))
JsonHelper.parseAndValidateJson[LayerViewConfiguration](_))
} yield {
category match {
case Category.segmentation =>
Expand Down
2 changes: 1 addition & 1 deletion app/models/binary/explore/RemoteLayerExplorer.scala
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,7 @@ trait RemoteLayerExplorer extends FoxImplicits {
protected def parseJsonFromPath[T: Reads](path: Path): Fox[T] =
for {
fileAsString <- tryo(new String(Files.readAllBytes(path), StandardCharsets.UTF_8)).toFox ?~> "Failed to read remote file"
parsed <- JsonHelper.parseJsonToFox[T](fileAsString) ?~> "Failed to validate json against data schema"
parsed <- JsonHelper.parseAndValidateJson[T](fileAsString) ?~> "Failed to validate json against data schema"
} yield parsed

protected def looksLikeSegmentationLayer(layerName: String, elementClass: ElementClass.Value): Boolean =
Expand Down
4 changes: 2 additions & 2 deletions app/models/organization/OrganizationService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -45,12 +45,12 @@ class OrganizationService @Inject()(organizationDAO: OrganizationDAO,
)
}

def findOneByInviteByNameOrDefault(inviteOpt: Option[Invite], organizatioNameOpt: Option[String])(
def findOneByInviteByNameOrDefault(inviteOpt: Option[Invite], organizationNameOpt: Option[String])(
implicit ctx: DBAccessContext): Fox[Organization] =
inviteOpt match {
case Some(invite) => organizationDAO.findOne(invite._organization)
case None =>
organizatioNameOpt match {
organizationNameOpt match {
case Some(organizationName) => organizationDAO.findOneByName(organizationName)
case None =>
for {
Expand Down
2 changes: 1 addition & 1 deletion app/models/user/MultiUser.scala
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,7 @@ class MultiUserDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext

def parse(r: MultiusersRow): Fox[MultiUser] =
for {
novelUserExperienceInfos <- JsonHelper.parseJsonToFox[JsObject](r.noveluserexperienceinfos).toFox
novelUserExperienceInfos <- JsonHelper.parseAndValidateJson[JsObject](r.noveluserexperienceinfos).toFox
theme <- Theme.fromString(r.selectedtheme).toFox
} yield {
MultiUser(
Expand Down
4 changes: 2 additions & 2 deletions app/models/user/User.scala
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ package models.user

import com.mohiva.play.silhouette.api.{Identity, LoginInfo}
import com.scalableminds.util.accesscontext._
import com.scalableminds.util.tools.JsonHelper.parseJsonToFox
import com.scalableminds.util.tools.JsonHelper.parseAndValidateJson
import com.scalableminds.util.tools.{Fox, FoxImplicits}
import com.scalableminds.webknossos.datastore.models.datasource.DataSetViewConfiguration.DataSetViewConfiguration
import com.scalableminds.webknossos.datastore.models.datasource.LayerViewConfiguration.LayerViewConfiguration
Expand Down Expand Up @@ -65,7 +65,7 @@ class UserDAO @Inject()(sqlClient: SQLClient)(implicit ec: ExecutionContext)

def parse(r: UsersRow): Fox[User] =
for {
userConfiguration <- parseJsonToFox[JsObject](r.userconfiguration)
userConfiguration <- parseAndValidateJson[JsObject](r.userconfiguration)
} yield {
User(
ObjectId(r._Id),
Expand Down
3 changes: 3 additions & 0 deletions app/models/user/UserService.scala
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,9 @@ class UserService @Inject()(conf: WkConf,
_ <- multiUserDAO.updatePasswordInfo(user._multiUser, passwordInfo)(GlobalAccessContext)
} yield passwordInfo

def getOpenIdConnectPasswordInfo: PasswordInfo =
PasswordInfo("Empty", "")

def updateUserConfiguration(user: User, configuration: JsObject)(implicit ctx: DBAccessContext): Fox[Unit] =
userDAO.updateUserConfiguration(user._id, configuration).map { result =>
userCache.invalidateUser(user._id)
Expand Down
Loading