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}