From 0a7c28ea34d7eeb0467e582048a85d2c60a38098 Mon Sep 17 00:00:00 2001 From: timzaak Date: Thu, 15 Jun 2023 22:58:39 +0800 Subject: [PATCH] keycloak compatible for SaaS and self-hosted (#14) fix #3 --- admin-web/src/api/infoAPI.ts | 2 +- admin-web/src/layout/AppLayout.tsx | 2 +- backend/src/main/resources/application.conf | 2 + .../fornet/controller/NetworkController.scala | 15 +++-- .../main/scala/com/timzaak/fornet/di/DI.scala | 36 +++++++----- .../KeycloakJWTSaaSAuthStrategy.scala | 35 ++++++++++++ .../KeycloakJWTSaaSCompatAuthStrategy.scala | 56 +++++++++++++++++++ .../keycloak/KeycloakJWTAuthStrategy.scala | 14 ++--- 8 files changed, 130 insertions(+), 32 deletions(-) create mode 100644 backend/src/main/scala/com/timzaak/fornet/keycloak/KeycloakJWTSaaSAuthStrategy.scala create mode 100644 backend/src/main/scala/com/timzaak/fornet/keycloak/KeycloakJWTSaaSCompatAuthStrategy.scala diff --git a/admin-web/src/api/infoAPI.ts b/admin-web/src/api/infoAPI.ts index 2c103f0..911b1dc 100644 --- a/admin-web/src/api/infoAPI.ts +++ b/admin-web/src/api/infoAPI.ts @@ -1,7 +1,7 @@ import http from "./http"; import {KeycloakConfig} from "keycloak-js"; -type AuthTypeResponse = { type: 'ST' } | (KeycloakConfig & { type: 'Bearer' }) +type AuthTypeResponse = { type: 'ST' } | (KeycloakConfig & { type: 'Bearer', saas: boolean }) export function getAppInfo() { return http.get('/info') } diff --git a/admin-web/src/layout/AppLayout.tsx b/admin-web/src/layout/AppLayout.tsx index 0febb2c..a5185b4 100644 --- a/admin-web/src/layout/AppLayout.tsx +++ b/admin-web/src/layout/AppLayout.tsx @@ -14,7 +14,7 @@ export default function AppLayout() { return (
- ForNet Admin + ForNet Manager (BETA)
diff --git a/backend/src/main/resources/application.conf b/backend/src/main/resources/application.conf index e40a8b8..673df3e 100644 --- a/backend/src/main/resources/application.conf +++ b/backend/src/main/resources/application.conf @@ -39,8 +39,10 @@ auth { # authServerUrl: "http://keycloak-dev.fornetcode.com", # frontClientId : "fornet", # # the user who has admin role can login in admin web, if undefined, anyone in the keycloak of realm can login + # # when server.saas enabled, this is useless # adminRole: "admin", # # the user who has client role can login in client, if undefined, anyone in the keycloak of realm can login + # # when server.saas enabled, this is useless # clientRole: "client", #} #simple { diff --git a/backend/src/main/scala/com/timzaak/fornet/controller/NetworkController.scala b/backend/src/main/scala/com/timzaak/fornet/controller/NetworkController.scala index ad83167..11ae82d 100644 --- a/backend/src/main/scala/com/timzaak/fornet/controller/NetworkController.scala +++ b/backend/src/main/scala/com/timzaak/fornet/controller/NetworkController.scala @@ -3,27 +3,26 @@ package com.timzaak.fornet.controller import com.google.common.net.InetAddresses import com.timzaak.fornet.config.AppConfig import com.timzaak.fornet.controller.auth.AppAuthSupport -import com.timzaak.fornet.dao.{DB, Network, NetworkDao, NetworkProtocol, NetworkSetting} +import com.timzaak.fornet.dao.* import com.timzaak.fornet.pubsub.NodeChangeNotifyService import com.typesafe.config.Config import org.hashids.Hashids import very.util.security.IntID import java.util.Base64 -//import org.json4s.Formats import com.timzaak.fornet.dao.NetworkStatus import io.getquill.* +import org.scalatra.* import org.scalatra.i18n.I18nSupport import org.scalatra.json.* -import org.scalatra.* +import very.util.security.IntID.toIntID import very.util.web.Controller import very.util.web.validate.ValidationExtra -import very.util.security.IntID.toIntID import zio.json.{ DeriveJsonDecoder, JsonDecoder } import java.time.OffsetDateTime -case class CreateNetworkReq(name: String, addressRange: String, protocol:NetworkProtocol) +case class CreateNetworkReq(name: String, addressRange: String, protocol: NetworkProtocol) given JsonDecoder[CreateNetworkReq] = DeriveJsonDecoder.gen case class UpdateNetworkReq( name: String, @@ -55,8 +54,8 @@ trait NetworkController( .filter(_.groupId == lift(groupId)) )(_.sortBy(_.id)(Ord.desc)) case _ => - val r= pageWithCount( - query[Network].filter(_.status == lift(NetworkStatus.Normal)) + val r = pageWithCount( + query[Network].filter(_.status == lift(NetworkStatus.Normal)).filter(_.groupId == lift(groupId)) )(_.sortBy(_.id)(Ord.desc)) import zio.json.* val j = r.toJson @@ -132,7 +131,7 @@ trait NetworkController( ) } } - nodeChangeNotifyService.networkSettingChange(oldNetwork, networkDao.findById(id).get) + nodeChangeNotifyService.networkSettingChange(oldNetwork, networkDao.findById(id).get) case _ => } Accepted() diff --git a/backend/src/main/scala/com/timzaak/fornet/di/DI.scala b/backend/src/main/scala/com/timzaak/fornet/di/DI.scala index 450a3c1..ecbccd3 100644 --- a/backend/src/main/scala/com/timzaak/fornet/di/DI.scala +++ b/backend/src/main/scala/com/timzaak/fornet/di/DI.scala @@ -1,14 +1,15 @@ package com.timzaak.fornet.di -import com.timzaak.fornet.config.{AppConfig, AppConfigImpl} +import com.timzaak.fornet.config.{ AppConfig, AppConfigImpl } import com.timzaak.fornet.controller.* import com.timzaak.fornet.grpc.AuthGRPCController +import com.timzaak.fornet.keycloak.{ KeycloakJWTSaaSAuthStrategy, KeycloakJWTSaaSCompatAuthStrategy } import com.timzaak.fornet.mqtt.MqttCallbackController import com.timzaak.fornet.mqtt.api.RMqttApiClient -import com.timzaak.fornet.pubsub.{MqttConnectionManager, NodeChangeNotifyService} +import com.timzaak.fornet.pubsub.{ MqttConnectionManager, NodeChangeNotifyService } import com.timzaak.fornet.service.* -import very.util.keycloak.{JWKPublicKeyLocator, JWKTokenVerifier, KeycloakJWTAuthStrategy} -import very.util.web.auth.{AuthStrategy, AuthStrategyProvider, SingleUserAuthStrategy} +import very.util.keycloak.{ JWKPublicKeyLocator, JWKTokenVerifier } +import very.util.web.auth.{ AuthStrategy, AuthStrategyProvider, SingleUserAuthStrategy } object DI extends DaoDI { di => object appConfig extends AppConfigImpl(config) @@ -43,17 +44,24 @@ object DI extends DaoDI { di => // init keycloak,( keycloak server must start, this would get information from keycloak server) val keycloakUrl = config.get[String]("auth.keycloak.authServerUrl") val realm = config.get[String]("auth.keycloak.realm") - val publicKeyLocator = JWKPublicKeyLocator.init( - keycloakUrl, - realm, - ) - List( - KeycloakJWTAuthStrategy( - JWKTokenVerifier(publicKeyLocator.get, keycloakUrl, realm), - config.getOptional[String]("auth.keycloak.adminRole"), - config.getOptional[String]("auth.keycloak.clientRole"), + val publicKeyLocator = JWKPublicKeyLocator + .init( + keycloakUrl, + realm, ) - ) + .get + val verifier = JWKTokenVerifier(publicKeyLocator, keycloakUrl, realm) + if (appConfig.enableSAAS) { + List(KeycloakJWTSaaSAuthStrategy(verifier)) + } else { + List( + KeycloakJWTSaaSCompatAuthStrategy( + verifier, + config.getOptional[String]("auth.keycloak.adminRole"), + config.getOptional[String]("auth.keycloak.clientRole"), + ) + ) + } } else { List( SingleUserAuthStrategy( diff --git a/backend/src/main/scala/com/timzaak/fornet/keycloak/KeycloakJWTSaaSAuthStrategy.scala b/backend/src/main/scala/com/timzaak/fornet/keycloak/KeycloakJWTSaaSAuthStrategy.scala new file mode 100644 index 0000000..4696665 --- /dev/null +++ b/backend/src/main/scala/com/timzaak/fornet/keycloak/KeycloakJWTSaaSAuthStrategy.scala @@ -0,0 +1,35 @@ +package com.timzaak.fornet.keycloak + +import org.scalatra.auth.ScentrySupport +import org.scalatra.auth.strategy.BasicAuthSupport +import com.typesafe.scalalogging.LazyLogging +import org.keycloak.TokenVerifier +import com.typesafe.scalalogging.Logger +import very.util.keycloak.{ JWKTokenVerifier, KeycloakJWTAuthStrategy } +import very.util.web.auth.AuthStrategy + +import scala.util.{ Failure, Success } + +class KeycloakJWTSaaSAuthStrategy( + jwkTokenVerifier: JWKTokenVerifier, +) extends AuthStrategy[String] + with LazyLogging { + + def name: String = KeycloakJWTAuthStrategy.name + + def adminAuth(token: String): Option[String] = { + jwkTokenVerifier.verify(token) match { + case Success(accessToken) => + Some(accessToken.getSubject) + case Failure(exception) => + logger.debug(s"bad token:$token", exception) + None + } + } + + // SaaS do not support Client SSO Login + def clientAuth(token: String): Option[String] = { + None + } + +} diff --git a/backend/src/main/scala/com/timzaak/fornet/keycloak/KeycloakJWTSaaSCompatAuthStrategy.scala b/backend/src/main/scala/com/timzaak/fornet/keycloak/KeycloakJWTSaaSCompatAuthStrategy.scala new file mode 100644 index 0000000..c42271b --- /dev/null +++ b/backend/src/main/scala/com/timzaak/fornet/keycloak/KeycloakJWTSaaSCompatAuthStrategy.scala @@ -0,0 +1,56 @@ +package com.timzaak.fornet.keycloak + +import org.scalatra.auth.ScentrySupport +import org.scalatra.auth.strategy.BasicAuthSupport +import com.typesafe.scalalogging.LazyLogging +import org.keycloak.TokenVerifier +import com.typesafe.scalalogging.Logger +import very.util.keycloak.{ JWKTokenVerifier, KeycloakJWTAuthStrategy } +import very.util.web.auth.AuthStrategy + +import scala.util.{ Failure, Success } + +class KeycloakJWTSaaSCompatAuthStrategy( + jwkTokenVerifier: JWKTokenVerifier, + adminRole: Option[String], + clientRole: Option[String] +) extends AuthStrategy[String] + with LazyLogging { + // JWT + def name: String = KeycloakJWTAuthStrategy.name + + def adminAuth(token: String): Option[String] = { + jwkTokenVerifier.verify(token) match { + case Success(accessToken) => + if (adminRole.fold(true)(role => accessToken.getRealmAccess.getRoles.contains(role))) { + Some(adminRole.getOrElse("admin")) + } else { + logger.info( + s"the user:${accessToken.getSubject} could not pass admin auth" + ) + None + } + case Failure(exception) => + logger.debug(s"bad token:$token", exception) + None + } + } + + def clientAuth(token: String): Option[String] = { + jwkTokenVerifier.verify(token) match { + case Success(accessToken) => + if (clientRole.fold(true)(role => accessToken.getRealmAccess.getRoles.contains(role))) { + Some(accessToken.getSubject) + } else { + logger.info( + s"the user:${accessToken.getSubject} could not pass client auth" + ) + None + } + case Failure(exception) => + logger.debug(s"bad token:$token", exception) + None + } + } + +} diff --git a/backend/src/main/scala/very/util/keycloak/KeycloakJWTAuthStrategy.scala b/backend/src/main/scala/very/util/keycloak/KeycloakJWTAuthStrategy.scala index ba20ce2..3ff8d18 100644 --- a/backend/src/main/scala/very/util/keycloak/KeycloakJWTAuthStrategy.scala +++ b/backend/src/main/scala/very/util/keycloak/KeycloakJWTAuthStrategy.scala @@ -9,17 +9,15 @@ import very.util.web.auth.AuthStrategy import scala.util.{ Success, Failure } -class KeycloakJWTAuthStrategy(jwkTokenVerifier: JWKTokenVerifier, adminRole: Option[String], clientRole:Option[String]) - extends AuthStrategy[String] { - def logger: Logger = com.typesafe.scalalogging.Logger(getClass.getName) - +class KeycloakJWTAuthStrategy(jwkTokenVerifier: JWKTokenVerifier, adminRole: Option[String], clientRole: Option[String]) + extends AuthStrategy[String] with LazyLogging { // JWT def name: String = KeycloakJWTAuthStrategy.name def adminAuth(token: String): Option[String] = { jwkTokenVerifier.verify(token) match { case Success(accessToken) => - if (adminRole.isEmpty || accessToken.getRealmAccess.getRoles.contains(adminRole.get)) { + if (adminRole.fold(true)(role => accessToken.getRealmAccess.getRoles.contains(role))) { Some(accessToken.getSubject) } else { logger.info( @@ -33,10 +31,10 @@ class KeycloakJWTAuthStrategy(jwkTokenVerifier: JWKTokenVerifier, adminRole: Opt } } - def clientAuth(token:String):Option[String] = { + def clientAuth(token: String): Option[String] = { jwkTokenVerifier.verify(token) match { case Success(accessToken) => - if (clientRole.isEmpty||accessToken.getRealmAccess.getRoles.contains(clientRole.get)) { + if (clientRole.fold(true)(role => accessToken.getRealmAccess.getRoles.contains(role))) { Some(accessToken.getSubject) } else { logger.info( @@ -53,5 +51,5 @@ class KeycloakJWTAuthStrategy(jwkTokenVerifier: JWKTokenVerifier, adminRole: Opt } object KeycloakJWTAuthStrategy { - val name:String = "Bearer" + val name: String = "Bearer" }