diff --git a/admin/src/main/scala/com/neu/api/authentication/OpenIdAuthApi.scala b/admin/src/main/scala/com/neu/api/authentication/OpenIdAuthApi.scala index bbc198609..e9dad986d 100644 --- a/admin/src/main/scala/com/neu/api/authentication/OpenIdAuthApi.scala +++ b/admin/src/main/scala/com/neu/api/authentication/OpenIdAuthApi.scala @@ -4,6 +4,11 @@ import com.neu.api.* import com.neu.service.Utils import com.neu.service.authentication.AuthService import org.apache.pekko.http.scaladsl.server.Route +import org.apache.pekko.http.scaladsl.model.headers.HttpCookie + +import java.nio.charset.StandardCharsets +import java.util.Base64 +import java.util.UUID //noinspection UnstableApiUsage class OpenIdAuthApi( @@ -19,13 +24,29 @@ class OpenIdAuthApi( optionalHeaderValueByName("Host") { host => parameter(Symbol("serverName").?) { serverName => Utils.respondWithWebServerHeaders() { - authService.getResources( - code, - state, - ip.toString(), - host, - serverName - ) + if (code.isEmpty && state.isEmpty) { + // Initial request: generate nonce and set cookie + val nonce = java.util.UUID.randomUUID().toString + val encodedNonce = + Base64.getEncoder.encodeToString(nonce.getBytes(StandardCharsets.UTF_8)) + setCookie(HttpCookie("temp", encodedNonce)) { + authService.getResources(code, state, ip.toString(), host, serverName, nonce) + } + } else { + // OAuth callback: read nonce from existing cookie, do not overwrite it + optionalCookie("temp") { tempCookie => + val nonce = tempCookie + .flatMap(c => + scala.util + .Try( + new String(Base64.getDecoder.decode(c.value), StandardCharsets.UTF_8) + ) + .toOption + ) + .getOrElse("") + authService.getResources(code, state, ip.toString(), host, serverName, nonce) + } + } } } } @@ -34,8 +55,17 @@ class OpenIdAuthApi( } ~ (patch & path(openId)) { extractClientIP { ip => - Utils.respondWithWebServerHeaders() { - authService.validateToken(None, Some(ip)) + optionalCookie("temp") { tempCookie => + val nonce = tempCookie.flatMap(c => + scala.util + .Try(new String(Base64.getDecoder.decode(c.value), StandardCharsets.UTF_8)) + .toOption + ) + Utils.respondWithWebServerHeaders() { + deleteCookie("temp") { + authService.validateToken(None, Some(ip), nonce) + } + } } } } diff --git a/admin/src/main/scala/com/neu/api/authentication/SamlAuthApi.scala b/admin/src/main/scala/com/neu/api/authentication/SamlAuthApi.scala index 0e5537f69..b1a995739 100644 --- a/admin/src/main/scala/com/neu/api/authentication/SamlAuthApi.scala +++ b/admin/src/main/scala/com/neu/api/authentication/SamlAuthApi.scala @@ -22,15 +22,19 @@ class SamlAuthApi( private val saml = "token_auth_server" private val samlslo = "token_auth_server_slo" private val rootPath = "/" - private val samlKey = "samlSso" val route: Route = (get & path(saml)) { extractClientIP { _ => optionalHeaderValueByName("Host") { host => parameter(Symbol("serverName").?) { serverName => - Utils.respondWithWebServerHeaders() { - authService.getResources(None, None, "", host, serverName) + val nonce = java.util.UUID.randomUUID().toString + val encodedNonce = + Base64.getEncoder.encodeToString(nonce.getBytes(StandardCharsets.UTF_8)) + setCookie(HttpCookie("temp", encodedNonce)) { + Utils.respondWithWebServerHeaders() { + authService.getResources(None, None, "", host, serverName, nonce) + } } } } @@ -38,8 +42,19 @@ class SamlAuthApi( } ~ (patch & path(saml)) { extractClientIP { _ => - Utils.respondWithWebServerHeaders() { - authService.validateToken(None, None) + optionalCookie("temp") { tempCookie => + val nonce = tempCookie.flatMap { c => + scala.util + .Try( + new String(Base64.getDecoder.decode(c.value), StandardCharsets.UTF_8) + ) + .toOption + } + deleteCookie("temp") { + Utils.respondWithWebServerHeaders() { + authService.validateToken(None, None, nonce) + } + } } } } ~ @@ -47,11 +62,16 @@ class SamlAuthApi( extractClientIP { ip => optionalHeaderValueByName("Host") { case Some(host) => - val text = Base64.getEncoder.encodeToString(samlKey.getBytes(StandardCharsets.UTF_8)) - - setCookie(HttpCookie("temp", text)) { + optionalCookie("temp") { tempCookie => + val nonce = tempCookie + .flatMap(c => + scala.util + .Try(new String(Base64.getDecoder.decode(c.value), StandardCharsets.UTF_8)) + .toOption + ) + .getOrElse("") extractRequestContext { ctx => - authService.login(ip, host, ctx) + authService.login(ip, host, ctx, nonce) } } case None => diff --git a/admin/src/main/scala/com/neu/api/authentication/SuseAuthApi.scala b/admin/src/main/scala/com/neu/api/authentication/SuseAuthApi.scala index afaf38688..88b90259e 100644 --- a/admin/src/main/scala/com/neu/api/authentication/SuseAuthApi.scala +++ b/admin/src/main/scala/com/neu/api/authentication/SuseAuthApi.scala @@ -18,7 +18,7 @@ class SuseAuthApi( extractClientIP { ip => extractRequestContext { ctx => Utils.respondWithWebServerHeaders() { - authService.login(ip, "", ctx) + authService.login(ip, "", ctx, "") } } } @@ -34,7 +34,7 @@ class SuseAuthApi( pathPrefix("heartbeat") { patch { Utils.respondWithWebServerHeaders() { - authService.validateToken(Some(tokenId), None) + authService.validateToken(Some(tokenId), None, None) } } } ~ diff --git a/admin/src/main/scala/com/neu/core/AuthenticationManager.scala b/admin/src/main/scala/com/neu/core/AuthenticationManager.scala index c5936873e..196161f44 100644 --- a/admin/src/main/scala/com/neu/core/AuthenticationManager.scala +++ b/admin/src/main/scala/com/neu/core/AuthenticationManager.scala @@ -22,6 +22,28 @@ object AuthenticationManager extends LazyLogging { private val tokenMap: mutable.Map[String, UserTokenNew] = scala.collection.mutable.Map[String, UserTokenNew]() + // Per-attempt SSO tokens keyed by nonce; entries expire after 5 minutes + private val ssoTtlMs: Long = 5L * 60 * 1000 + private val pendingSsoMap: mutable.Map[String, (UserTokenNew, Long)] = mutable.Map.empty + + def putSsoToken(nonce: String, userToken: UserTokenNew): Unit = { + cleanExpiredSsoTokens() + pendingSsoMap += nonce -> (userToken, System.currentTimeMillis()) + } + + def validateSsoToken(nonce: String): Option[UserTokenNew] = + pendingSsoMap.get(nonce).collect { + case (t, ts) if System.currentTimeMillis() - ts <= ssoTtlMs => t + } + + def invalidateSsoToken(nonce: String): Unit = + pendingSsoMap -= nonce + + private def cleanExpiredSsoTokens(): Unit = { + val now = System.currentTimeMillis() + pendingSsoMap.filterInPlace { case (_, (_, ts)) => now - ts <= ssoTtlMs } + } + private val tokenClusterMap: mutable.Map[String, String] = scala.collection.mutable.Map[String, String]() diff --git a/admin/src/main/scala/com/neu/model/AuthTokenJsonProtocol.scala b/admin/src/main/scala/com/neu/model/AuthTokenJsonProtocol.scala index 407fb82de..133955ecf 100644 --- a/admin/src/main/scala/com/neu/model/AuthTokenJsonProtocol.scala +++ b/admin/src/main/scala/com/neu/model/AuthTokenJsonProtocol.scala @@ -46,9 +46,9 @@ object AuthTokenJsonProtocol extends DefaultJsonProtocol { given groupMappedRoleFormat: RootJsonFormat[GroupMappedRole] = jsonFormat3(GroupMappedRole.apply) given ldapServerFormat: RootJsonFormat[LdapServer] = jsonFormat13(LdapServer.apply) - given samlServerFormat: RootJsonFormat[SamlServer] = jsonFormat13(SamlServer.apply) + given samlServerFormat: RootJsonFormat[SamlServer] = jsonFormat14(SamlServer.apply) given ssoServerFormat: RootJsonFormat[SsoServer] = jsonFormat3(SsoServer.apply) - given samlTokenFormat: RootJsonFormat[SamlToken] = jsonFormat3(SamlToken.apply) + given samlTokenFormat: RootJsonFormat[SamlToken] = jsonFormat4(SamlToken.apply) given samlResponseFormat: RootJsonFormat[SamlResponse] = jsonFormat3(SamlResponse.apply) given serverOIDCFormat: RootJsonFormat[ServerOIDC] = jsonFormat11(ServerOIDC.apply) @@ -58,8 +58,8 @@ object AuthTokenJsonProtocol extends DefaultJsonProtocol { given openIdServerConfigFormat: RootJsonFormat[OpenIdServerConfig] = jsonFormat2( OpenIdServerConfig.apply ) - given redirectURLFormat: RootJsonFormat[RedirectURL] = jsonFormat1(RedirectURL.apply) - given samlRedirectURLFormat: RootJsonFormat[SamlRedirectURL] = jsonFormat2(SamlRedirectURL.apply) + given redirectURLFormat: RootJsonFormat[RedirectURL] = jsonFormat2(RedirectURL.apply) + given samlRedirectURLFormat: RootJsonFormat[SamlRedirectURL] = jsonFormat3(SamlRedirectURL.apply) given ldapSettingFormat: RootJsonFormat[LdapSetting] = jsonFormat4(LdapSetting.apply) given ldapSettingWrapFormat: RootJsonFormat[LdapSettingWrap] = jsonFormat1(LdapSettingWrap.apply) diff --git a/admin/src/main/scala/com/neu/model/OpenIdServer.scala b/admin/src/main/scala/com/neu/model/OpenIdServer.scala index 5498b710d..47e9242c2 100644 --- a/admin/src/main/scala/com/neu/model/OpenIdServer.scala +++ b/admin/src/main/scala/com/neu/model/OpenIdServer.scala @@ -75,4 +75,4 @@ case class OpenIdServerConfig(name: String, oidc: OpenIdServer) * @param redirect_endpoint * the redirect url */ -case class RedirectURL(redirect_endpoint: String) +case class RedirectURL(redirect_endpoint: String, sso_nonce: Option[String] = None) diff --git a/admin/src/main/scala/com/neu/model/SamlServer.scala b/admin/src/main/scala/com/neu/model/SamlServer.scala index c42785a66..0bfba7b44 100644 --- a/admin/src/main/scala/com/neu/model/SamlServer.scala +++ b/admin/src/main/scala/com/neu/model/SamlServer.scala @@ -16,7 +16,8 @@ case class SamlServer( slo_enabled: Option[Boolean], slo_url: Option[String], signing_cert: Option[String], - signing_key: Option[String] + signing_key: Option[String], + audience_uri: Option[String] ) case class SamlConfig(name: String, saml: SamlServer) @@ -25,12 +26,17 @@ case class SamlConfigWrap(config: SamlConfig) case class SsoServer(server_name: String, server_type: String, redirect_url: String) -case class SamlRedirectURL(redirect_endpoint: String, issuer: String) +case class SamlRedirectURL( + redirect_endpoint: String, + issuer: String, + sso_nonce: Option[String] = None +) case class SamlToken( token: String, state: Option[String] = None, - redirect_endpoint: Option[String] = None + redirect_endpoint: Option[String] = None, + sso_nonce: Option[String] = None ) case class SamlResponse( diff --git a/admin/src/main/scala/com/neu/service/authentication/AuthServiceFactory.scala b/admin/src/main/scala/com/neu/service/authentication/AuthServiceFactory.scala index d8d91b6dc..14cf75f21 100644 --- a/admin/src/main/scala/com/neu/service/authentication/AuthServiceFactory.scala +++ b/admin/src/main/scala/com/neu/service/authentication/AuthServiceFactory.scala @@ -21,12 +21,17 @@ trait AuthService extends BaseService with DefaultJsonFormats with LazyLogging { state: Option[String], ip: String, host: Option[String], - serverName: Option[String] + serverName: Option[String], + nonce: String ): Route - def validateToken(tokenId: Option[String], ip: Option[RemoteAddress]): Route + def validateToken( + tokenId: Option[String], + ip: Option[RemoteAddress], + nonce: Option[String] + ): Route - def login(ip: RemoteAddress, host: String, ctx: RequestContext): Route + def login(ip: RemoteAddress, host: String, ctx: RequestContext, nonce: String): Route def logout(host: Option[String], tokenId: String): Route diff --git a/admin/src/main/scala/com/neu/service/authentication/OpenIdAuthService.scala b/admin/src/main/scala/com/neu/service/authentication/OpenIdAuthService.scala index 08ea68be2..7f35f0d5d 100644 --- a/admin/src/main/scala/com/neu/service/authentication/OpenIdAuthService.scala +++ b/admin/src/main/scala/com/neu/service/authentication/OpenIdAuthService.scala @@ -28,41 +28,47 @@ class OpenIdAuthService()(implicit private val AUTH_SERVER = "token_auth_server" private val OPEN_ID = "openId_auth" private val ROOT_PATH = "/" - private val KEY = "samlSso" override def getResources( code: Option[String], state: Option[String], ip: String, host: Option[String], - serverName: Option[String] + serverName: Option[String], + nonce: String ): Route = { logger.info("get openId_auth: {}", host.get) if (state.isEmpty) { val result = - Await.result(handleEmptyResources(host, serverName), RestClient.waitingLimit.seconds) + Await.result(handleEmptyResources(host, serverName, nonce), RestClient.waitingLimit.seconds) complete(result) } else { Await - .result(handleExistingResoureces(code, state, ip, host), RestClient.waitingLimit.seconds) + .result( + handleExistingResoureces(code, state, ip, host, nonce), + RestClient.waitingLimit.seconds + ) } } - override def validateToken(tokenId: Option[String], ip: Option[RemoteAddress]): Route = { + override def validateToken( + tokenId: Option[String], + ip: Option[RemoteAddress], + nonce: Option[String] + ): Route = { logger.info("openId-pt: to validate authToken from {}", ip.get) - val authToken = AuthenticationManager.validate(KEY) - authToken match { - case Some(x) => + nonce.flatMap(AuthenticationManager.validateSsoToken) match { + case Some(token) => logger.info("openId-pt: authToken is matched and valid.") - AuthenticationManager.invalidate(KEY) - complete(x) - case _ => + nonce.foreach(AuthenticationManager.invalidateSsoToken) + complete(token) + case _ => logger.info("openId-pt: no authToken") complete((StatusCodes.Unauthorized, authError)) } } - override def login(ip: RemoteAddress, host: String, ctx: RequestContext): Route = + override def login(ip: RemoteAddress, host: String, ctx: RequestContext, nonce: String): Route = complete((StatusCodes.MethodNotAllowed, "Method not allowed.")) override def logout(host: Option[String], tokenId: String): Route = @@ -79,7 +85,8 @@ class OpenIdAuthService()(implicit private def handleEmptyResources( host: Option[String], - serverName: Option[String] + serverName: Option[String], + nonce: String ): Future[JsValue] = if (serverName.isEmpty) { logger.info("openId-g: no server name.") @@ -93,7 +100,7 @@ class OpenIdAuthService()(implicit RestClient.httpRequest( s"$baseUri/$AUTH_SERVER/openId1", HttpMethods.POST, - redirectUrlToJson(RedirectURL(s"https://${host.get}/$OPEN_ID")) + redirectUrlToJson(RedirectURL(s"https://${host.get}/$OPEN_ID", Some(nonce))) ) } } @@ -102,47 +109,46 @@ class OpenIdAuthService()(implicit code: Option[String], state: Option[String], ip: String, - host: Option[String] + host: Option[String], + nonce: String ): Future[Route] = Future { logger.info(s"openId-g: state is ${state.get}.") logger.info(s"openId-g: code is ${code.getOrElse("no code")}") logger.info(s"openId-g: host is ${host.getOrElse("no host")}") - val text = Base64.getEncoder.encodeToString(KEY.getBytes(StandardCharsets.UTF_8)) - val cookie = HttpCookie("temp", text) - - setCookie(cookie) { ctx => - val result = RestClient.passHttpRequest( - s"$baseUri/$auth/openId1", - HttpMethods.POST, - samlResponseToJson( - SamlResponse( - client_ip = ip, - Token = Some( - SamlToken( - code.getOrElse(""), - state, - host.map(h => s"https://$h/$OPEN_ID") - ) + val result = RestClient.passHttpRequest( + s"$baseUri/$auth/openId1", + HttpMethods.POST, + samlResponseToJson( + SamlResponse( + client_ip = ip, + Token = Some( + SamlToken( + code.getOrElse(""), + state, + host.map(h => s"https://$h/$OPEN_ID"), + Some(nonce) ) ) ) ) - val response = Await.result(result, RestClient.waitingLimit.seconds) - logger.info("openId-g: OpenId Login. ") + ) + val response = Await.result(result, RestClient.waitingLimit.seconds) + logger.info("openId-g: OpenId Login. ") - response.status match { - case StatusCodes.OK => - val authTokenFuture: Future[String] = Unmarshal(response.entity).to[String] - val authToken = Await.result(authTokenFuture, RestClient.waitingLimit.seconds) - val userToken: UserTokenNew = AuthenticationManager.parseToken(authToken) - logger.info("openId-g: added authToken") - AuthenticationManager.putToken(KEY, userToken) - ctx.redirect(ROOT_PATH, StatusCodes.Found) - case _ => - logger.warn("openId-g: invalid response. redirect /") - ctx.redirect(ROOT_PATH, StatusCodes.MovedPermanently) - } + response.status match { + case StatusCodes.OK => + val authTokenFuture: Future[String] = Unmarshal(response.entity).to[String] + val authToken = Await.result(authTokenFuture, RestClient.waitingLimit.seconds) + val userToken: UserTokenNew = AuthenticationManager.parseToken(authToken) + logger.info("openId-g: added authToken") + AuthenticationManager.putSsoToken(nonce, userToken) + redirect(ROOT_PATH, StatusCodes.Found) + case _ => + logger.warn("openId-g: invalid response. redirect /") + deleteCookie("temp") { + redirect(ROOT_PATH, StatusCodes.MovedPermanently) + } } } } diff --git a/admin/src/main/scala/com/neu/service/authentication/SamlAuthService.scala b/admin/src/main/scala/com/neu/service/authentication/SamlAuthService.scala index 9842d22be..154560c41 100644 --- a/admin/src/main/scala/com/neu/service/authentication/SamlAuthService.scala +++ b/admin/src/main/scala/com/neu/service/authentication/SamlAuthService.scala @@ -27,14 +27,14 @@ class SamlAuthService()(implicit val saml = "token_auth_server" val samlslo = "token_auth_server_slo" private val rootPath = "/" - private val samlKey = "samlSso" override def getResources( code: Option[String], state: Option[String], ip: String, host: Option[String], - serverName: Option[String] + serverName: Option[String], + nonce: String ): Route = { val resourcesFuture = if (serverName.isEmpty) { logger.info("saml-g: servername is empty") @@ -47,7 +47,8 @@ class SamlAuthService()(implicit samlRedirectUrlToJson( SamlRedirectURL( s"https://${host.getOrElse("")}/$saml", - s"https://${host.getOrElse("")}/$saml" + s"https://${host.getOrElse("")}/$saml", + Some(nonce) ) ) ) @@ -58,13 +59,16 @@ class SamlAuthService()(implicit complete(result) } - override def validateToken(tokenId: Option[String], ip: Option[RemoteAddress]): Route = { + override def validateToken( + tokenId: Option[String], + ip: Option[RemoteAddress], + nonce: Option[String] + ): Route = { logger.info("saml-pt: to validate authToken.") - val authToken = AuthenticationManager.validate(samlKey) - authToken match { + nonce.flatMap(AuthenticationManager.validateSsoToken) match { case Some(token) => logger.info("saml-pt: authToken matched.") - AuthenticationManager.invalidate(samlKey) + nonce.foreach(AuthenticationManager.invalidateSsoToken) complete(token) case None => logger.info("saml-pt: no authToken.") @@ -72,9 +76,9 @@ class SamlAuthService()(implicit } } - override def login(ip: RemoteAddress, host: String, ctx: RequestContext): Route = { + override def login(ip: RemoteAddress, host: String, ctx: RequestContext, nonce: String): Route = { logger.info(s"saml-p: $host") - onComplete(processLoginRequest(ctx, ip, host)) { + onComplete(processLoginRequest(ctx, ip, host, nonce)) { case Success(route) => route case Failure(ex) => logger.error("Login process failed", ex) @@ -110,18 +114,20 @@ class SamlAuthService()(implicit private def processLoginRequest( ctx: RequestContext, ip: RemoteAddress, - host: String + host: String, + nonce: String ): Future[Route] = for { entityString <- Unmarshal(ctx.request.entity).to[String] - response <- makeAuthRequest(entityString, ip.toString, host) - route <- handleAuthResponse(response) + response <- makeAuthRequest(entityString, ip.toString, host, nonce) + route <- handleAuthResponse(response, nonce) } yield route private def makeAuthRequest( entityString: String, ip: String, - host: String + host: String, + nonce: String ): Future[HttpResponse] = RestClient.passHttpRequest( s"$baseUri/$auth/saml1", @@ -133,26 +139,32 @@ class SamlAuthService()(implicit SamlToken( entityString, None, - Some(s"https://$host/$saml") + Some(s"https://$host/$saml"), + Some(nonce) ) ) ) ) ) - private def handleAuthResponse(response: HttpResponse): Future[Route] = { - logger.info("saml-p: added temp cookie.") + private def handleAuthResponse(response: HttpResponse, nonce: String): Future[Route] = { + logger.info("saml-p: processing auth response.") response.status match { case StatusCodes.OK => - logger.info(s"saml-p: added authToken. redirect to $rootPath") + logger.info(s"saml-p: success, storing token under nonce. redirecting to $rootPath") Unmarshal(response.entity).to[String].map { authToken => val userToken: UserTokenNew = AuthenticationManager.parseToken(authToken) - AuthenticationManager.putToken("samlSso", userToken) + AuthenticationManager.putSsoToken(nonce, userToken) redirect(rootPath, StatusCodes.Found) } case _ => logger.warn(s"saml-p: ${response.status}. SAML login error. redirect to $rootPath ") - Future.successful(redirect(rootPath, StatusCodes.MovedPermanently)) + // Clear the temp cookie so the browser does not retain a stale nonce + Future.successful( + deleteCookie("temp") { + redirect(rootPath, StatusCodes.MovedPermanently) + } + ) } } } diff --git a/admin/src/main/scala/com/neu/service/authentication/SuseAuthService.scala b/admin/src/main/scala/com/neu/service/authentication/SuseAuthService.scala index 57ccf3225..060b79d44 100644 --- a/admin/src/main/scala/com/neu/service/authentication/SuseAuthService.scala +++ b/admin/src/main/scala/com/neu/service/authentication/SuseAuthService.scala @@ -34,15 +34,20 @@ class SuseAuthService()(implicit state: Option[String], ip: String, host: Option[String], - serverName: Option[String] + serverName: Option[String], + nonce: String ): Route = complete((StatusCodes.MethodNotAllowed, "Method not allowed.")) - override def validateToken(tokenId: Option[String], ip: Option[RemoteAddress]): Route = + override def validateToken( + tokenId: Option[String], + ip: Option[RemoteAddress], + nonce: Option[String] + ): Route = complete { AuthenticationManager.validateToken(tokenId.get) } - override def login(ip: RemoteAddress, host: String, ctx: RequestContext): Route = { + override def login(ip: RemoteAddress, host: String, ctx: RequestContext, nonce: String): Route = { val suseCookieOpt = ctx.request.cookies.find(_.name == suseCookieName) val bodyFuture: Future[String] = ctx.request.entity match { case HttpEntity.Strict(_, data) => diff --git a/admin/webapp/websrc/app/routes/settings/saml/saml-form/saml-form.component.html b/admin/webapp/websrc/app/routes/settings/saml/saml-form/saml-form.component.html index 9d8412391..d037b4549 100644 --- a/admin/webapp/websrc/app/routes/settings/saml/saml-form/saml-form.component.html +++ b/admin/webapp/websrc/app/routes/settings/saml/saml-form/saml-form.component.html @@ -45,6 +45,19 @@ +