001/* 002 * Copyright 2017 Anyware Services 003 * 004 * Licensed under the Apache License, Version 2.0 (the "License"); 005 * you may not use this file except in compliance with the License. 006 * You may obtain a copy of the License at 007 * 008 * http://www.apache.org/licenses/LICENSE-2.0 009 * 010 * Unless required by applicable law or agreed to in writing, software 011 * distributed under the License is distributed on an "AS IS" BASIS, 012 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 013 * See the License for the specific language governing permissions and 014 * limitations under the License. 015 */ 016package org.ametys.plugins.site; 017 018import java.io.File; 019import java.io.FileOutputStream; 020import java.io.IOException; 021import java.io.InputStream; 022import java.io.OutputStream; 023import java.util.ArrayList; 024import java.util.Base64; 025import java.util.Collection; 026import java.util.Comparator; 027import java.util.HashMap; 028import java.util.List; 029import java.util.Map; 030import java.util.Properties; 031import java.util.TreeMap; 032import java.util.concurrent.locks.Lock; 033import java.util.concurrent.locks.ReentrantLock; 034 035import javax.xml.transform.OutputKeys; 036import javax.xml.transform.TransformerFactory; 037import javax.xml.transform.sax.SAXTransformerFactory; 038import javax.xml.transform.sax.TransformerHandler; 039import javax.xml.transform.stream.StreamResult; 040 041import org.apache.avalon.framework.component.Component; 042import org.apache.avalon.framework.configuration.Configuration; 043import org.apache.avalon.framework.configuration.ConfigurationException; 044import org.apache.avalon.framework.configuration.DefaultConfigurationBuilder; 045import org.apache.avalon.framework.configuration.DefaultConfigurationSerializer; 046import org.apache.avalon.framework.service.ServiceException; 047import org.apache.avalon.framework.service.ServiceManager; 048import org.apache.avalon.framework.service.Serviceable; 049import org.apache.commons.io.IOUtils; 050import org.apache.commons.lang3.Strings; 051import org.apache.hc.client5.http.classic.methods.HttpGet; 052import org.apache.hc.client5.http.impl.classic.CloseableHttpClient; 053import org.apache.hc.core5.http.HttpEntity; 054import org.apache.xml.serializer.OutputPropertiesFactory; 055import org.xml.sax.SAXException; 056 057import org.ametys.core.captcha.Captcha; 058import org.ametys.core.captcha.CaptchaExtensionPoint; 059import org.ametys.core.captcha.CaptchaHelper; 060import org.ametys.core.datasource.LDAPDataSourceManager; 061import org.ametys.core.datasource.SQLDataSourceManager; 062import org.ametys.core.user.population.UserPopulationDAO; 063import org.ametys.core.util.LambdaUtils.LambdaException; 064import org.ametys.runtime.config.Config; 065import org.ametys.runtime.config.ConfigManager; 066import org.ametys.runtime.exception.ServiceUnavailableException; 067import org.ametys.runtime.model.type.ElementType; 068import org.ametys.runtime.plugin.component.AbstractLogEnabled; 069import org.ametys.runtime.servlet.RuntimeServlet; 070import org.ametys.runtime.util.AmetysHomeHelper; 071import org.ametys.site.BackOfficeRequestHelper; 072 073/** 074 * A cache for site information provided by the Back-Office. 075 */ 076public class SiteInformationCache extends AbstractLogEnabled implements Serviceable, Component 077{ 078 /** Avalon Role */ 079 public static final String ROLE = SiteInformationCache.class.getName(); 080 081 /** Prefix for backoffice synchronized userpopulations */ 082 public static final String BACKOFFICE_PREFIX_IDENTIFIER = "bo-"; 083 084 private final Lock _syncLock = new ReentrantLock(); 085 086 private Map<SiteUrl, Site> _sites; 087 088 private LDAPDataSourceManager _ldapDataSourceManager; 089 private SQLDataSourceManager _sqlDataSourceManager; 090 private UserPopulationDAO _userPopulationDAO; 091 private CaptchaExtensionPoint _captchaExtensionPoint; 092 093 public void service(ServiceManager manager) throws ServiceException 094 { 095 _sqlDataSourceManager = (SQLDataSourceManager) manager.lookup(SQLDataSourceManager.ROLE); 096 _ldapDataSourceManager = (LDAPDataSourceManager) manager.lookup(LDAPDataSourceManager.ROLE); 097 _userPopulationDAO = (UserPopulationDAO) manager.lookup(UserPopulationDAO.ROLE); 098 _captchaExtensionPoint = (CaptchaExtensionPoint) manager.lookup(CaptchaExtensionPoint.ROLE); 099 } 100 101 /** 102 * Clear cached informations. 103 */ 104 public void resetSitesCache() 105 { 106 _sites = null; 107 } 108 109 /** 110 * Returns the cached informations. 111 * @return the cached informations. 112 */ 113 public Map<SiteUrl, Site> getSites() 114 { 115 _synchronize(); 116 return _sites; 117 } 118 119 private void _synchronize() 120 { 121 if (_sites == null) 122 { 123 if (_syncLock.tryLock()) 124 { 125 try 126 { 127 _synchronizeSites(); 128 _synchronizePopulationsAndDatasources(); 129 } 130 catch (Exception e) 131 { 132 throw new RuntimeException("Unable to synchronize sites data", e); 133 } 134 finally 135 { 136 _syncLock.unlock(); 137 } 138 } 139 else 140 { 141 // some is already filling _sites for us... we just have to wait 142 _syncLock.lock(); 143 _syncLock.unlock(); 144 } 145 } 146 } 147 148 private void _synchronizeSites() throws ConfigurationException 149 { 150 Collection<Site> sites = new ArrayList<>(); 151 152 Configuration boConfiguration = _getBackofficeConfiguration("/_sites.xml"); 153 _configureSites (boConfiguration, sites); 154 155 TreeMap<SiteUrl, Site> sortedSites = new TreeMap<>(new SiteUrlComparator()); 156 157 for (Site site : sites) 158 { 159 for (SiteUrl url : site.getSiteUrls()) 160 { 161 sortedSites.put(url, site); 162 } 163 } 164 165 _sites = sortedSites; 166 } 167 168 private void _configureSites (Configuration conf, Collection<Site> sites) throws ConfigurationException 169 { 170 Configuration[] sitesConf = conf.getChildren("site"); 171 for (Configuration siteConf : sitesConf) 172 { 173 String name = siteConf.getAttribute("name"); 174 String title = siteConf.getAttribute("title"); 175 176 List<String> populationIds = new ArrayList<>(); 177 for (Configuration populationConf : siteConf.getChild("populations").getChildren()) 178 { 179 populationIds.add(populationConf.getValue()); 180 } 181 182 List<SiteUrl> urls = new ArrayList<>(); 183 for (Configuration urlConf : siteConf.getChildren("url")) 184 { 185 String serverName = urlConf.getAttribute("serverName"); 186 String serverPort = urlConf.getAttribute("serverPort"); 187 String serverPath = urlConf.getAttribute("serverPath"); 188 189 urls.add(new SiteUrl(serverName, serverPort, serverPath)); 190 } 191 192 List<String> languages = new ArrayList<>(); 193 for (Configuration langConf : siteConf.getChild("languages").getChildren()) 194 { 195 languages.add(langConf.getName()); 196 } 197 198 // Get sign-up pages 199 Map<String, List<SignupPage>> signupPages = _configureSignupPages(siteConf, name); 200 201 // Get weak password url 202 Map<String, String> weakPasswordUrls = _configureWeakPasswordUrls(siteConf); 203 204 // Add the site to the sites 205 sites.add(new Site(name, title, urls, languages, populationIds, signupPages, weakPasswordUrls)); 206 207 // Sub sites 208 _configureSites (siteConf, sites); 209 } 210 } 211 212 private Map<String , List<SignupPage>> _configureSignupPages(Configuration siteConf, String siteName) throws ConfigurationException 213 { 214 Map<String , List<SignupPage>> signupPagesByLang = new HashMap<>(); 215 Configuration signupPagesConf = siteConf.getChild("signupPages"); 216 217 boolean publicSignupAllowed = signupPagesConf.getAttributeAsBoolean("publicSignupAllowed", false); 218 if (publicSignupAllowed) 219 { 220 // For every child available for "signupPages" 221 for (Configuration langConf : signupPagesConf.getChildren()) 222 { 223 String lang = langConf.getName(); 224 225 List<SignupPage> pages = new ArrayList<>(); 226 227 for (Configuration pageConf : langConf.getChildren("page")) 228 { 229 String url = pageConf.getChild("url").getValue(); 230 231 Map<String, String> popAndUserDirIds = new HashMap<>(); 232 for (Configuration userDirectoryConf : pageConf.getChildren("userDirectory")) 233 { 234 popAndUserDirIds.put(userDirectoryConf.getAttribute("populationId"), userDirectoryConf.getAttribute("id")); 235 } 236 237 pages.add(new SignupPage(url, siteName, lang, popAndUserDirIds)); 238 } 239 240 signupPagesByLang.put(lang, pages); 241 } 242 } 243 244 return signupPagesByLang; 245 } 246 247 private Map<String, String> _configureWeakPasswordUrls(Configuration siteConf) throws ConfigurationException 248 { 249 Configuration weakPasswordUrlConf = siteConf.getChild("weakPasswordUrls"); 250 251 Map<String, String> weakPasswordUrls = new HashMap<>(); 252 253 for (Configuration langConf : weakPasswordUrlConf.getChildren()) 254 { 255 weakPasswordUrls.put(langConf.getName(), langConf.getValue()); 256 } 257 258 return weakPasswordUrls; 259 260 } 261 262 class SiteUrlComparator implements Comparator<SiteUrl> 263 { 264 @Override 265 public int compare(SiteUrl url1, SiteUrl url2) 266 { 267 int result = url2.getServerName().compareTo(url1.getServerName()); 268 269 if (result != 0) 270 { 271 return result; 272 } 273 274 result = url2.getServerPort().compareTo(url1.getServerPort()); 275 276 if (result != 0) 277 { 278 return result; 279 } 280 281 return url2.getServerPath().compareTo(url1.getServerPath()); 282 } 283 } 284 285 private Configuration _getBackofficeConfiguration(String url) 286 { 287 // Get site names and URLs from the CMS 288 String cmsURL = Config.getInstance().getValue("org.ametys.site.bo"); 289 290 // FIXME CMS-12715 Reuse the same client for all request from site to cms 291 try (CloseableHttpClient httpClient = BackOfficeRequestHelper.getHttpClient()) 292 { 293 HttpGet httpGet = new HttpGet(cmsURL + url); 294 httpGet.addHeader("X-Ametys-FO", "true"); 295 296 return httpClient.execute(httpGet, response -> { 297 switch (response.getCode()) 298 { 299 case 200: 300 break; 301 302 case 403: 303 throw new IllegalStateException("The CMS back-office refused the connection"); 304 305 case 503: 306 BackOfficeRequestHelper.switchOnMaintenanceIfNeeded(response); 307 throw new ServiceUnavailableException(); 308 309 case 500: 310 default: 311 throw new IllegalStateException("The CMS back-office returned an error"); 312 } 313 314 try (HttpEntity entity = response.getEntity(); 315 InputStream is = entity.getContent()) 316 { 317 return new DefaultConfigurationBuilder().build(is); 318 } 319 catch (ConfigurationException | SAXException e) 320 { 321 throw new LambdaException(e); 322 } 323 }); 324 } 325 catch (Exception e) 326 { 327 if (e instanceof LambdaException lambda) 328 { 329 e = (Exception) lambda.getCause(); // we can cast because we know that we only wrapped exception 330 } 331 throw new RuntimeException("Unable to synchronize site data", e); 332 } 333 } 334 335 /** 336 * Synchronize the local user populations and datasources with backoffice 337 * @throws Exception If an unexpected error occurred 338 */ 339 private void _synchronizePopulationsAndDatasources() throws Exception 340 { 341 Configuration configuration = _getBackofficeConfiguration("/_sites-populations.xml"); 342 343 // Peppers 344 _synchronizePeppers(configuration); 345 346 // Multifactor authentication files 347 _synchronizeMultifactorAuthenticationFiles(configuration); 348 349 // First, we need to remove the files because they will be read before we reinitialize them by the DataSourceConsumerEP 350 _userPopulationDAO.getConfigurationFile().delete(); 351 _sqlDataSourceManager.getFileConfiguration().delete(); 352 _ldapDataSourceManager.getFileConfiguration().delete(); 353 354 // Then we can destroy what is in memory 355 _userPopulationDAO.dispose(true); 356 _sqlDataSourceManager.dispose(true); 357 _ldapDataSourceManager.dispose(); 358 359 // And finally we can transfer data 360 361 // SQL datasources 362 Configuration sqlDatasources = configuration.getChild("SQLDatasources").getChild("datasources"); 363 364 _serialize(sqlDatasources, _sqlDataSourceManager.getFileConfiguration()); 365 _sqlDataSourceManager.initialize(true); 366 367 // LDAP Datasources 368 _serialize(configuration.getChild("LDAPDatasources").getChild("datasources"), _ldapDataSourceManager.getFileConfiguration()); 369 _ldapDataSourceManager.initialize(); 370 371 // User populations 372 _serialize(configuration.getChild("UserPopulations").getChild("userPopulations"), _userPopulationDAO.getConfigurationFile()); 373 _userPopulationDAO.initialize(); 374 375 // Config values 376 _synchronizeConfigValues(configuration); 377 } 378 379 private void _synchronizePeppers(Configuration configuration) throws ConfigurationException, IOException 380 { 381 Configuration peppersConfiguration = configuration.getChild("Peppers"); 382 383 for (Configuration pepperConfiguration : peppersConfiguration.getChildren()) 384 { 385 String name = pepperConfiguration.getName(); 386 String value = pepperConfiguration.getValue(); 387 388 File pepperFile = new File(AmetysHomeHelper.getAmetysHomeData() + "/auth", name); 389 pepperFile.createNewFile(); 390 391 byte[] pepperBytes = Base64.getDecoder().decode(value); 392 try (FileOutputStream fileOutputStream = new FileOutputStream(pepperFile)) 393 { 394 IOUtils.write(pepperBytes, fileOutputStream); 395 } 396 } 397 398 } 399 400 private void _synchronizeMultifactorAuthenticationFiles(Configuration configuration) throws ConfigurationException, IOException 401 { 402 Configuration mfaConfigurations = configuration.getChild("MultifactorAuthentication"); 403 404 for (Configuration mfaConfiguration : mfaConfigurations.getChildren()) 405 { 406 String name = mfaConfiguration.getName(); 407 String value = mfaConfiguration.getValue(); 408 409 if (name.startsWith("mfa_")) 410 { 411 File mfaFile = new File(AmetysHomeHelper.getAmetysHomeConfig() + "/mfa", name); 412 mfaFile.createNewFile(); 413 414 byte[] mfaBytes = Base64.getDecoder().decode(value); 415 try (FileOutputStream fileOutputStream = new FileOutputStream(mfaFile)) 416 { 417 IOUtils.write(mfaBytes, fileOutputStream); 418 } 419 } 420 } 421 } 422 423 private void _serialize(Configuration configuration, File file) throws Exception 424 { 425 if (!file.exists()) 426 { 427 file.getParentFile().mkdirs(); 428 file.createNewFile(); 429 } 430 431 // create a transformer for saving sax into a file 432 TransformerHandler th = ((SAXTransformerFactory) TransformerFactory.newInstance()).newTransformerHandler(); 433 434 // create the result where to write 435 try (OutputStream os = new FileOutputStream(file)) 436 { 437 StreamResult sResult = new StreamResult(os); 438 th.setResult(sResult); 439 440 // create the format of result 441 Properties format = new Properties(); 442 format.put(OutputKeys.METHOD, "xml"); 443 format.put(OutputKeys.INDENT, "yes"); 444 format.put(OutputKeys.ENCODING, "UTF-8"); 445 format.put(OutputPropertiesFactory.S_KEY_INDENT_AMOUNT, "4"); 446 th.getTransformer().setOutputProperties(format); 447 448 new DefaultConfigurationSerializer().serialize(th, configuration); 449 } 450 } 451 452 private void _synchronizeConfigValues(Configuration boConfiguration) 453 { 454 Map<String, Object> valuesToChange = new HashMap<>(); 455 456 _synchronizeMonitory(boConfiguration, valuesToChange); 457 _synchronizeCaptcha(boConfiguration, valuesToChange); 458 _synchronizeUpload(boConfiguration, valuesToChange); 459 _synchronizeMultifactorAuthenticationDatasource(boConfiguration, valuesToChange); 460 _synchronizeUsersStatusDatasource(boConfiguration, valuesToChange); 461 462 // UPDATE CONFIG 463 if (!valuesToChange.isEmpty()) 464 { 465 try 466 { 467 Map<String, Object> existingValues = Config.getInstance().getValues(); 468 existingValues.putAll(valuesToChange); 469 ConfigManager.getInstance().save(existingValues, new File(AmetysHomeHelper.getAmetysHomeConfig(), RuntimeServlet.CONFIG_FILE_NAME).getCanonicalPath()); 470 // Need a restart to be taken in account 471 } 472 catch (Exception e) 473 { 474 getLogger().error("The monitoring/captcha synchronization failed", e); 475 } 476 } 477 } 478 479 private void _synchronizeUpload(Configuration boConfiguration, Map<String, Object> valuesToChange) 480 { 481 Configuration uploadConfiguration = boConfiguration.getChild("UploadMaxSize"); 482 Long uploadMaxSize = uploadConfiguration.getValueAsLong(-1); 483 484 if (uploadMaxSize != Config.getInstance().getValue("runtime.upload.max-size")) 485 { 486 valuesToChange.put("runtime.upload.max-size", uploadMaxSize); 487 } 488 } 489 490 private void _synchronizeCaptcha(Configuration boConfiguration, Map<String, Object> valuesToChange) 491 { 492 Configuration captchaConfiguration = boConfiguration.getChild("Captcha"); 493 String captchaType = captchaConfiguration.getAttribute("type", null); 494 495 if (!Strings.CS.equals(captchaType, Config.getInstance().getValue("runtime.captcha.type"))) 496 { 497 // If the captcha has changed, reinitialize the captcha from CaptchaHelper 498 CaptchaHelper.staticDispose(); 499 valuesToChange.put("runtime.captcha.type", captchaType); 500 } 501 502 Captcha newCaptcha = _captchaExtensionPoint.getExtension(captchaType); 503 if (newCaptcha == null) 504 { 505 throw new IllegalArgumentException("The captcha extension '" + captchaType + "' is unknown on the site. Missing plugin?"); 506 } 507 508 Map<String, Object> parameterValues = new HashMap<>(); 509 510 boolean valueChanged = false; 511 for (String parameter : newCaptcha.getConfigParameters()) 512 { 513 ElementType elementType = (ElementType) ConfigManager.getInstance().getModelItem(parameter).getType(); 514 Object newValue = elementType.castValue(captchaConfiguration.getAttribute(parameter, null)); 515 // Store the captcha parameter 516 parameterValues.put(parameter, newValue); 517 // Check if a parameter value has changed 518 if (!valueChanged) 519 { 520 Object oldValue = elementType.castValue(Config.getInstance().getValue(parameter)); 521 // We have a new value different from the previous one or no more value when one was present 522 valueChanged = newValue != null && !newValue.equals(oldValue) || newValue == null && oldValue != null; 523 } 524 } 525 526 // If any parameters has changed, we update them all 527 if (valueChanged) 528 { 529 valuesToChange.putAll(parameterValues); 530 } 531 } 532 533 private void _synchronizeMonitory(Configuration boConfiguration, Map<String, Object> valuesToChange) 534 { 535 Configuration monitoringConfiguration = boConfiguration.getChild("Monitoring"); 536 boolean enabled = monitoringConfiguration.getAttributeAsBoolean("enabled", false); 537 boolean wasEnabled = Config.getInstance().getValue("front.cache.monitoring.schedulers.enable"); 538 String datasourceId = monitoringConfiguration.getChild("Datasource").getValue(""); 539 540 if (enabled != wasEnabled) 541 { 542 valuesToChange.put("front.cache.monitoring.schedulers.enable", enabled); 543 } 544 if (enabled && !Strings.CS.equals(datasourceId, Config.getInstance().getValue("front.cache.monitoring.datasource.jdbc.pool"))) 545 { 546 valuesToChange.put("front.cache.monitoring.datasource.jdbc.pool", datasourceId); 547 } 548 549 } 550 551 private void _synchronizeMultifactorAuthenticationDatasource(Configuration boConfiguration, Map<String, Object> valuesToChange) 552 { 553 Configuration mfaDatasourceConfiguration = boConfiguration.getChild("MultifactorAuthentication") 554 .getChild("Datasource"); 555 String mfaDatasourceId = mfaDatasourceConfiguration.getValue(""); 556 557 if (!Strings.CS.equals(mfaDatasourceId, Config.getInstance().getValue("runtime.assignments.multifactorauthentication"))) 558 { 559 valuesToChange.put("runtime.assignments.multifactorauthentication", mfaDatasourceId); 560 } 561 } 562 563 private void _synchronizeUsersStatusDatasource(Configuration boConfiguration, Map<String, Object> valuesToChange) 564 { 565 Configuration userStatusDatasourceConfiguration = boConfiguration.getChild("UsersStatus") 566 .getChild("Datasource"); 567 String userStatusDatasourceId = userStatusDatasourceConfiguration.getValue(""); 568 569 if (!Strings.CS.equals(userStatusDatasourceId, Config.getInstance().getValue("runtime.users.status.datasource"))) 570 { 571 valuesToChange.put("runtime.users.status.datasource", userStatusDatasourceId); 572 } 573 } 574 575 /** 576 * Record of the SignupPage parameters 577 * @param url The sign-up page URL 578 * @param siteName the page site name 579 * @param lang the page language 580 * @param userDirByPop The userDirectories Ids and populations Ids 581 */ 582 public record SignupPage (String url, String siteName, String lang, Map<String, String> userDirByPop) { } 583 584}