Skip to content

Commit

Permalink
Certificate validation (#8320)
Browse files Browse the repository at this point in the history
* adds certificate public key

* add certificate validation

* add certificate validation

* fix proofreading message

* format backend

* fix frontend

* fix wording

* fix snapshots

* fix typos and improve verification

* styling

* rename file

* include expiration date

* use formatted date

* unify with application conf naming

* format backend

* fix typecheck tests

* improve comments

* do not block if server is down

* apply frontend feedback

* increase certificate cache time

* apply backend feedback

* rename proofreading enabling var

* add new image

* format backend

* fix single sign on

* make certificate route accessible to unauthorized users

* fix image sizing

* Update app/security/CertificateValidationService.scala

Co-authored-by: frcroth <[email protected]>

---------

Co-authored-by: Michael Büßemeyer <[email protected]>
Co-authored-by: MichaelBuessemeyer <[email protected]>
Co-authored-by: frcroth <[email protected]>
  • Loading branch information
4 people authored Jan 23, 2025
1 parent 7f49dda commit 4e33465
Show file tree
Hide file tree
Showing 20 changed files with 668 additions and 30 deletions.
2 changes: 2 additions & 0 deletions app/WebknossosModule.scala
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import models.task.TaskService
import models.user._
import models.user.time.TimeSpanService
import models.voxelytics.LokiClient
import security.CertificateValidationService
import telemetry.SlackNotificationService
import utils.sql.SqlClient

Expand Down Expand Up @@ -39,5 +40,6 @@ class WebknossosModule extends AbstractModule {
bind(classOf[UsedStorageService]).asEagerSingleton()
bind(classOf[ThumbnailCachingService]).asEagerSingleton()
bind(classOf[TracingDataSourceTemporaryStore]).asEagerSingleton()
bind(classOf[CertificateValidationService]).asEagerSingleton()
}
}
15 changes: 12 additions & 3 deletions app/controllers/Application.scala
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ import org.apache.pekko.actor.ActorSystem
import play.api.libs.json.Json
import play.api.mvc.{Action, AnyContent, Result}
import play.silhouette.api.Silhouette
import security.WkEnv
import security.{CertificateValidationService, WkEnv}
import utils.sql.{SimpleSQLDAO, SqlClient}
import utils.{ApiVersioning, StoreModules, WkConf}

Expand All @@ -23,7 +23,8 @@ class Application @Inject()(actorSystem: ActorSystem,
conf: WkConf,
defaultMails: DefaultMails,
storeModules: StoreModules,
sil: Silhouette[WkEnv])(implicit ec: ExecutionContext)
sil: Silhouette[WkEnv],
certificateValidationService: CertificateValidationService)(implicit ec: ExecutionContext)
extends Controller
with ApiVersioning {

Expand All @@ -38,7 +39,8 @@ class Application @Inject()(actorSystem: ActorSystem,
addRemoteOriginHeaders(
Ok(
Json.obj(
"webknossos" -> Json.toJson(webknossos.BuildInfo.toMap.view.mapValues(_.toString).toMap),
"webknossos" -> Json.toJson(
webknossos.BuildInfo.toMap.view.mapValues(_.toString).filterKeys(_ != "certificatePublicKey").toMap),
"schemaVersion" -> schemaVersion.toOption,
"httpApiVersioning" -> Json.obj(
"currentApiVersion" -> CURRENT_API_VERSION,
Expand All @@ -63,6 +65,13 @@ class Application @Inject()(actorSystem: ActorSystem,
addNoCacheHeaderFallback(Ok("Ok"))
}

def checkCertificate: Action[AnyContent] = Action.async { implicit request =>
certificateValidationService.checkCertificateCached().map {
case (true, expiresAt) => Ok(Json.obj("isValid" -> true, "expiresAt" -> expiresAt))
case (false, expiresAt) => BadRequest(Json.obj("isValid" -> false, "expiresAt" -> expiresAt))
}
}

def helpEmail(message: String, currentUrl: String): Action[AnyContent] = sil.SecuredAction.async { implicit request =>
for {
organization <- organizationDAO.findOne(request.identity._organization)
Expand Down
11 changes: 10 additions & 1 deletion app/controllers/AuthenticationController.scala
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ class AuthenticationController @Inject()(
private lazy val ssoKey =
conf.WebKnossos.User.ssoKey

private lazy val isOIDCEnabled = conf.Features.openIdConnectEnabled

def register: Action[AnyContent] = Action.async { implicit request =>
signUpForm
.bindFromRequest()
Expand Down Expand Up @@ -408,7 +410,13 @@ class AuthenticationController @Inject()(
private 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)))
if (!isOIDCEnabled) {
Fox.successful(BadRequest("SSO is not enabled"))
} else {
openIdConnectClient
.getRedirectUrl(absoluteOpenIdConnectCallbackURL)
.map(url => Ok(Json.obj("redirect_url" -> url)))
}
}

private def loginUser(loginInfo: LoginInfo)(implicit request: Request[AnyContent]): Future[Result] =
Expand Down Expand Up @@ -458,6 +466,7 @@ class AuthenticationController @Inject()(

def openIdCallback(): Action[AnyContent] = Action.async { implicit request =>
for {
_ <- bool2Fox(isOIDCEnabled) ?~> "SSO is not enabled"
(accessToken: JsObject, idToken: Option[JsObject]) <- openIdConnectClient.getAndValidateTokens(
absoluteOpenIdConnectCallbackURL,
request.queryString.get("code").flatMap(_.headOption).getOrElse("missing code"),
Expand Down
15 changes: 5 additions & 10 deletions app/controllers/Controller.scala
Original file line number Diff line number Diff line change
Expand Up @@ -6,23 +6,18 @@ import com.scalableminds.util.mvc.ExtendedController
import com.scalableminds.util.tools.Fox
import com.typesafe.scalalogging.LazyLogging
import models.user.User
import play.api.i18n.{I18nSupport, Messages, MessagesProvider}
import play.api.i18n.{Messages, MessagesProvider}
import play.api.libs.json._
import play.api.mvc.{InjectedController, Request, Result}
import security.{UserAwareRequestLogging, WkEnv}

import scala.concurrent.ExecutionContext

trait Controller
extends InjectedController
with ExtendedController
with UserAwareRequestLogging
with I18nSupport
with LazyLogging {
trait Controller extends InjectedController with ExtendedController with UserAwareRequestLogging with LazyLogging {

final val badRequestLabel = "Operation could not be performed. See JSON body for more information."

def jsonErrorWrites(errors: JsError)(implicit m: MessagesProvider): JsObject =
private def jsonErrorWrites(errors: JsError)(implicit m: MessagesProvider): JsObject =
Json.obj(
"errors" -> errors.errors.map(error =>
error._2.foldLeft(Json.obj("field" -> error._1.toJsonString)) {
Expand All @@ -42,8 +37,8 @@ trait Controller
f: A => Fox[Result])(implicit rds: Reads[A], m: MessagesProvider, ec: ExecutionContext): Fox[Result] =
withJsonUsing(json, rds)(f)

def withJsonUsing[A](json: JsReadable, reads: Reads[A])(f: A => Fox[Result])(implicit m: MessagesProvider,
ec: ExecutionContext): Fox[Result] =
private def withJsonUsing[A](json: JsReadable, reads: Reads[A])(
f: A => Fox[Result])(implicit m: MessagesProvider, ec: ExecutionContext): Fox[Result] =
json.validate(reads) match {
case JsSuccess(result, _) =>
f(result)
Expand Down
76 changes: 76 additions & 0 deletions app/security/CertificateValidationService.scala
Original file line number Diff line number Diff line change
@@ -0,0 +1,76 @@
package security

import com.scalableminds.util.cache.AlfuCache
import com.scalableminds.util.tools.Fox
import com.typesafe.scalalogging.LazyLogging
import net.liftweb.common.{Box, Empty, Failure, Full}

import java.security.{KeyFactory, PublicKey}
import pdi.jwt.{JwtJson, JwtOptions}

import java.security.spec.X509EncodedKeySpec
import java.util.Base64
import javax.inject.Inject
import scala.concurrent.ExecutionContext
import scala.concurrent.duration.DurationInt
import scala.util.Properties

class CertificateValidationService @Inject()(implicit ec: ExecutionContext) extends LazyLogging {

// The publicKeyBox is empty if no public key is provided, Failure if decoding the public key failed or Full if there is a valid public key.
private lazy val publicKeyBox: Box[PublicKey] = webknossos.BuildInfo.toMap.get("certificatePublicKey").flatMap {
case Some(value: String) => deserializePublicKey(value)
case None => Empty
}

private lazy val cache: AlfuCache[String, (Boolean, Long)] = AlfuCache(timeToLive = 1 hour)

private def deserializePublicKey(pem: String): Box[PublicKey] =
try {
val base64Key = pem.replaceAll("\\s", "")
val decodedKey = Base64.getDecoder.decode(base64Key)
val keySpec = new X509EncodedKeySpec(decodedKey)
Some(KeyFactory.getInstance("EC").generatePublic(keySpec))
} catch {
case _: Throwable =>
val message = s"Could not deserialize public key from PEM string: $pem"
logger.error(message)
Failure(message)
}

private def checkCertificate: (Boolean, Long) = publicKeyBox match {
case Full(publicKey) =>
(for {
certificate <- Properties.envOrNone("CERTIFICATE")
// JwtJson would throw an error in case the exp time of the token is expired. As we want to check the expiration
// date yourself, we don't want to throw an error.
token <- JwtJson.decodeJson(certificate, publicKey, JwtOptions(expiration = false)).toOption
expirationInSeconds <- (token \ "exp").asOpt[Long]
currentTimeInSeconds = System.currentTimeMillis() / 1000
isExpired = currentTimeInSeconds < expirationInSeconds
} yield (isExpired, expirationInSeconds)).getOrElse((false, 0L))
case Empty => (true, 0L) // No public key provided, so certificate is always valid.
case _ => (false, 0L) // Invalid public key provided, so certificate is always invalid.
}

def checkCertificateCached(): Fox[(Boolean, Long)] = cache.getOrLoad("c", _ => Fox.successful(checkCertificate))

private def defaultConfigOverridesMap: Map[String, Boolean] =
Map("openIdConnectEnabled" -> false, "segmentAnythingEnabled" -> false, "editableMappingsEnabled" -> false)

lazy val getFeatureOverrides: Map[String, Boolean] = publicKeyBox match {
case Full(publicKey) =>
(for {
certificate <- Properties.envOrNone("CERTIFICATE")
// JwtJson already throws an error which is transformed to an empty option when the certificate is expired.
// In case the token is expired, tge default map will be used.
token <- JwtJson.decodeJson(certificate, publicKey, JwtOptions(expiration = false)).toOption
featureOverrides <- Some(
(token \ "webknossos").asOpt[Map[String, Boolean]].getOrElse(defaultConfigOverridesMap))
featureOverridesWithDefaults = featureOverrides ++ defaultConfigOverridesMap.view.filterKeys(
!featureOverrides.contains(_))
} yield featureOverridesWithDefaults).getOrElse(defaultConfigOverridesMap)
case Empty => Map.empty
case _ => defaultConfigOverridesMap
}
}
14 changes: 11 additions & 3 deletions app/utils/WkConf.scala
Original file line number Diff line number Diff line change
Expand Up @@ -3,13 +3,17 @@ package utils
import com.scalableminds.util.tools.ConfigReader
import com.typesafe.scalalogging.LazyLogging
import play.api.Configuration
import security.CertificateValidationService

import java.time.Instant
import javax.inject.Inject
import scala.concurrent.duration._

class WkConf @Inject()(configuration: Configuration) extends ConfigReader with LazyLogging {
class WkConf @Inject()(configuration: Configuration, certificateValidationService: CertificateValidationService)
extends ConfigReader
with LazyLogging {
override def raw: Configuration = configuration
lazy val featureOverrides: Map[String, Boolean] = certificateValidationService.getFeatureOverrides

object Http {
val uri: String = get[String]("http.uri")
Expand Down Expand Up @@ -120,8 +124,12 @@ class WkConf @Inject()(configuration: Configuration) extends ConfigReader with L
val publicDemoDatasetUrl: String = get[String]("features.publicDemoDatasetUrl")
val exportTiffMaxVolumeMVx: Long = get[Long]("features.exportTiffMaxVolumeMVx")
val exportTiffMaxEdgeLengthVx: Long = get[Long]("features.exportTiffMaxEdgeLengthVx")
val openIdConnectEnabled: Boolean = get[Boolean]("features.openIdConnectEnabled")
val segmentAnythingEnabled: Boolean = get[Boolean]("features.segmentAnythingEnabled")
val openIdConnectEnabled: Boolean =
featureOverrides.getOrElse("openIdConnectEnabled", get[Boolean]("features.openIdConnectEnabled"))
val editableMappingsEnabled: Boolean =
featureOverrides.getOrElse("editableMappingsEnabled", get[Boolean]("features.editableMappingsEnabled"))
val segmentAnythingEnabled: Boolean =
featureOverrides.getOrElse("segmentAnythingEnabled", get[Boolean]("features.segmentAnythingEnabled"))
}

object Datastore {
Expand Down
1 change: 1 addition & 0 deletions conf/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@ features {
exportTiffMaxVolumeMVx = 1024
exportTiffMaxEdgeLengthVx = 8192
defaultToLegacyBindings = false
editableMappingsEnabled = false
# The only valid item value is currently "ConnectomeView":
optInTabs = []
openIdConnectEnabled = false
Expand Down
1 change: 1 addition & 0 deletions conf/webknossos.latest.routes
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
GET /buildinfo controllers.Application.buildInfo()
GET /features controllers.Application.features()
GET /health controllers.Application.health()
GET /checkCertificate controllers.Application.checkCertificate()
POST /analytics/ingest controllers.AnalyticsController.ingestAnalyticsEvents
POST /analytics/:eventType controllers.AnalyticsController.trackAnalyticsEvent(eventType)
POST /helpEmail controllers.Application.helpEmail(message: String, currentUrl: String)
Expand Down
24 changes: 24 additions & 0 deletions frontend/javascripts/admin/api/certificate_validation.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import Request from "libs/request";

type CertificateValidationResult = {
isValid: boolean;
expiresAt: number;
};

export async function isCertificateValid(): Promise<CertificateValidationResult> {
try {
return await Request.receiveJSON("/api/checkCertificate", { showErrorToast: false });
} catch (errorResponse: any) {
if (errorResponse.status !== 400) {
// In case the server is not available or some other kind of error occurred, we assume the certificate is valid.
return { isValid: true, expiresAt: 0 };
}
try {
const { isValid, expiresAt } = JSON.parse(errorResponse.errors[0]);
return { isValid, expiresAt };
} catch (_e) {
// If parsing the error message fails, we assume the certificate is valid.
return { isValid: true, expiresAt: 0 };
}
}
}
66 changes: 66 additions & 0 deletions frontend/javascripts/components/check_certificate_modal.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
import { isCertificateValid } from "admin/api/certificate_validation";
import { Col, Modal, Result, Row } from "antd";
import { useInterval } from "libs/react_helpers";
import _ from "lodash";
import { useEffect, useState } from "react";
import FormattedDate from "./formatted_date";

export function CheckCertificateModal() {
const [isValid, setIsValid] = useState(true);
const [expiresAt, setExpiresAt] = useState(Number.POSITIVE_INFINITY);
useEffect(() => {
isCertificateValid().then(({ isValid, expiresAt }) => {
setIsValid(isValid);
setExpiresAt(expiresAt);
});
}, []);
useInterval(
async () => {
const { isValid, expiresAt } = await isCertificateValid();
setIsValid(isValid);
setExpiresAt(expiresAt);
},
5 * 60 * 1000, // 5 minutes
);
if (isValid) {
return null;
}
return (
<Modal
open={true}
closable={false}
footer={null}
onCancel={_.noop}
width={"max(70%, 600px)"}
keyboard={false}
maskClosable={false}
>
<Row justify="center" align="middle" style={{ maxHeight: "50%", width: "auto" }}>
<Col>
<Result
icon={<i className="drawing drawing-license-expired" />}
status="warning"
title={
<span>
Sorry, your WEBKNOSSOS license expired on{" "}
<FormattedDate timestamp={expiresAt * 1000} format="YYYY-MM-DD" />.
<br />
Please{" "}
<a
target="_blank"
rel="noreferrer"
href="mailto:[email protected]"
style={{ color: "inherit", textDecoration: "underline" }}
>
contact us
</a>{" "}
to renew your license.
</span>
}
style={{ height: "100%" }}
/>
</Col>
</Row>
</Modal>
);
}
17 changes: 13 additions & 4 deletions frontend/javascripts/oxalis/view/action-bar/toolbar_view.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -917,6 +917,7 @@ export default function ToolbarView() {
isControlOrMetaPressed,
isAltPressed,
);
const areEditableMappingsEnabled = features().editableMappingsEnabled;

const skeletonToolDescription = useLegacyBindings
? "Use left-click to move around and right-click to create new skeleton nodes"
Expand Down Expand Up @@ -1123,12 +1124,17 @@ export default function ToolbarView() {
{hasSkeleton && hasVolume ? (
<ToolRadioButton
name={TOOL_NAMES.PROOFREAD}
description="Modify an agglomerated segmentation. Other segmentation modifications, like brushing, are not allowed if this tool is used."
description={
"Modify an agglomerated segmentation. Other segmentation modifications, like brushing, are not allowed if this tool is used."
}
disabledExplanation={
isAgglomerateMappingEnabled.reason ||
disabledInfosForTools[AnnotationToolEnum.PROOFREAD].explanation
areEditableMappingsEnabled
? isAgglomerateMappingEnabled.reason ||
disabledInfosForTools[AnnotationToolEnum.PROOFREAD].explanation
: "Proofreading tool is only available on webknossos.org"
}
disabled={
!areEditableMappingsEnabled ||
!isAgglomerateMappingEnabled.value ||
disabledInfosForTools[AnnotationToolEnum.PROOFREAD].isDisabled
}
Expand Down Expand Up @@ -1199,6 +1205,7 @@ function ToolSpecificSettings({
? "The quick select tool is now working without AI. Activate AI for better results."
: "The quick select tool is now working with AI."
: "The quick select tool with AI is only available on webknossos.org";
const areEditableMappingsEnabled = features().editableMappingsEnabled;
const toggleQuickSelectStrategy = () => {
dispatch(
updateUserSettingAction("quickSelect", {
Expand Down Expand Up @@ -1265,7 +1272,9 @@ function ToolSpecificSettings({

{adaptedActiveTool === AnnotationToolEnum.FILL_CELL ? <FloodFillSettings /> : null}

{adaptedActiveTool === AnnotationToolEnum.PROOFREAD ? <ProofReadingComponents /> : null}
{adaptedActiveTool === AnnotationToolEnum.PROOFREAD && areEditableMappingsEnabled ? (
<ProofReadingComponents />
) : null}

{MeasurementTools.includes(adaptedActiveTool) ? (
<MeasurementToolSwitch activeTool={adaptedActiveTool} />
Expand Down
Loading

0 comments on commit 4e33465

Please sign in to comment.