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