001/*
002 *  Copyright 2023 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.captchetat.captcha;
017
018import java.io.UnsupportedEncodingException;
019import java.nio.charset.StandardCharsets;
020import java.time.Duration;
021import java.util.ArrayList;
022import java.util.HashMap;
023import java.util.List;
024import java.util.Map;
025
026import org.apache.avalon.framework.activity.Initializable;
027import org.apache.avalon.framework.component.Component;
028import org.apache.avalon.framework.service.ServiceException;
029import org.apache.avalon.framework.service.ServiceManager;
030import org.apache.avalon.framework.service.Serviceable;
031import org.apache.commons.lang3.BooleanUtils;
032import org.apache.commons.lang3.StringUtils;
033import org.apache.commons.lang3.tuple.Pair;
034import org.apache.http.HttpEntity;
035import org.apache.http.NameValuePair;
036import org.apache.http.client.config.RequestConfig;
037import org.apache.http.client.entity.UrlEncodedFormEntity;
038import org.apache.http.client.methods.CloseableHttpResponse;
039import org.apache.http.client.methods.HttpPost;
040import org.apache.http.entity.ContentType;
041import org.apache.http.entity.StringEntity;
042import org.apache.http.impl.client.CloseableHttpClient;
043import org.apache.http.impl.client.HttpClientBuilder;
044import org.apache.http.message.BasicNameValuePair;
045import org.apache.http.util.EntityUtils;
046
047import org.ametys.core.cache.AbstractCacheManager;
048import org.ametys.core.cache.Cache;
049import org.ametys.core.util.JSONUtils;
050import org.ametys.runtime.config.Config;
051import org.ametys.runtime.i18n.I18nizableText;
052import org.ametys.runtime.plugin.component.AbstractLogEnabled;
053
054
055/**
056 * Captcha implementation with images
057 */
058public class CaptchEtatHelper extends AbstractLogEnabled implements Component, Initializable, Serviceable
059{
060
061    /** Role */
062    public static final String ROLE = CaptchEtatHelper.class.getName();
063    
064    /** id of the token cache */
065    public static final String CAPCHETAT_TOKEN_CACHE = CaptchetatReader.class.getName();
066    
067    private static final String CAPTCHA_CLIENT_ID = "captchetat.client_id";
068    private static final String CAPTCHA_CLIENT_SECRET = "captchetat.client_secret";
069    private static final String CAPTCHA_ENDPOINT = "captchetat.endpoint";
070    
071    private static final Map<Endpoint, String> CAPTCHA_ENDPOINT_AUTH = Map.of(
072            Endpoint.PRODUCTION, "https://oauth.piste.gouv.fr/api/oauth/token",
073            Endpoint.SANDBOX, "https://sandbox-oauth.piste.gouv.fr/api/oauth/token"
074    );
075    private static final Map<Endpoint, String> CAPTCHA_ENDPOINT_PISTE = Map.of(
076            Endpoint.PRODUCTION, "https://api.piste.gouv.fr/piste/captchetat/v2/",
077            Endpoint.SANDBOX, "https://sandbox-api.piste.gouv.fr/piste/captchetat/v2/"
078            );
079    
080    /** the endpoint present in configuration */
081    protected Endpoint _endpoint;
082    
083    private AbstractCacheManager _cacheManager;
084    private CloseableHttpClient _httpClient;
085    private JSONUtils _jsonUtils;
086
087    /**
088     * The type of endpoint available for Captchetat
089     */
090    protected enum Endpoint
091    {
092        /** To use the production endpoint */
093        PRODUCTION,
094        /** To use the sandbox endpoint */
095        SANDBOX
096    }
097    
098    @Override
099    public void service(ServiceManager smanager) throws ServiceException
100    {
101        _jsonUtils = (JSONUtils) smanager.lookup(JSONUtils.ROLE);
102        _cacheManager = (AbstractCacheManager) smanager.lookup(AbstractCacheManager.ROLE);
103    }
104
105    /**
106     * Initialize
107     */
108    @Override
109    public void initialize()
110    {
111        // A little bit less than an hour, so the token is still valid after user submit the captcha
112        Duration duration = Duration.ofMinutes(50);
113        if (!_cacheManager.hasCache(CAPCHETAT_TOKEN_CACHE))
114        {
115            _cacheManager.createMemoryCache(CAPCHETAT_TOKEN_CACHE,
116                    new I18nizableText("plugin.captchetat", "PLUGINS_CAPTCHETAT_CACHE_TOKEN_LABEL"),
117                    new I18nizableText("plugin.captchetat", "PLUGINS_CAPTCHETAT_CACHE_TOKEN_DESCRIPTION"),
118                    true,
119                    duration);
120        }
121
122        RequestConfig config = RequestConfig.custom()
123                .setConnectTimeout(20000)
124                .setConnectionRequestTimeout(20000)
125                .setSocketTimeout(20000).build();
126        
127        _httpClient = HttpClientBuilder.create()
128            .setDefaultRequestConfig(config)
129            .disableRedirectHandling()
130            .useSystemProperties()
131            .build();
132        
133        Config ametysConfig = Config.getInstance();
134        if (ametysConfig != null)
135        {
136            String endpoint = ametysConfig.<String>getValue(CAPTCHA_ENDPOINT);
137            // the endpoint is a mandatory config when using captchetat. If its not present, then this helper is
138            // unused so it doesn't matter that the component is not fully initialized
139            if (StringUtils.isNotBlank(endpoint))
140            {
141                _endpoint = Endpoint.valueOf(endpoint.toUpperCase());
142            }
143        }
144    }
145
146    private Cache<Pair<String, String>, String> _getCache()
147    {
148        return _cacheManager.get(CAPCHETAT_TOKEN_CACHE);
149    }
150    
151    private String _getToken(Pair<String, String> key)
152    {
153        return _getCache().get(key, this::_computeToken);
154    }
155
156    private String _computeToken(Pair<String, String> key)
157    {
158        String url = CAPTCHA_ENDPOINT_AUTH.get(_endpoint);
159        HttpPost httpPost = new HttpPost(url);
160        List<NameValuePair> pairs = new ArrayList<>();
161        pairs.add(new BasicNameValuePair("grant_type", "client_credentials"));
162        pairs.add(new BasicNameValuePair("client_id", key.getLeft()));
163        pairs.add(new BasicNameValuePair("client_secret", key.getRight()));
164        pairs.add(new BasicNameValuePair("scope", "piste.captchetat"));
165
166        try
167        {
168            httpPost.setEntity(new UrlEncodedFormEntity(pairs));
169        }
170        catch (UnsupportedEncodingException e)
171        {
172            throw new RuntimeException("Error while building request, can't compute token", e);
173        }
174        
175        try (CloseableHttpResponse response = _httpClient.execute(httpPost))
176        {
177            HttpEntity entity = response.getEntity();
178            
179            String responseString = EntityUtils.toString(entity, "UTF-8");
180            switch (response.getStatusLine().getStatusCode())
181            {
182                case 200:
183                    break;
184                
185                case 403:
186                    throw new IllegalStateException("The CMS back-office refused the connection");
187                    
188                case 500:
189                default:
190                    throw new IllegalStateException("The captchEtat token generator server returned an error " + response.getStatusLine().getStatusCode() + ": " + responseString);
191            }
192            Map<String, Object> result = _jsonUtils.convertJsonToMap(responseString);
193            return (String) result.get("access_token");
194        }
195        catch (Exception e)
196        {
197            throw new RuntimeException("Error during request, can't compute token", e);
198        }
199    }
200
201    /**
202     * get Token
203     * @return token
204     */
205    public String getToken()
206    {
207        return _getToken(Pair.of(Config.getInstance().getValue(CAPTCHA_CLIENT_ID), Config.getInstance().getValue(CAPTCHA_CLIENT_SECRET)));
208    }
209
210    /**
211     * get Token
212     * @return token
213     */
214    public String getEndpoint()
215    {
216        return CAPTCHA_ENDPOINT_PISTE.get(_endpoint);
217    }
218    
219    /**
220     * Check if the captcha is correct
221     * @param key the key
222     * @param value the value
223     * @return true if correct
224     */
225    public boolean checkAndInvalidateCaptcha(String key, String value)
226    {
227        String url = getEndpoint() + "valider-captcha";
228        HttpPost httpPost = new HttpPost(url);
229        String token = getToken();
230        httpPost.addHeader("Authorization", "Bearer " + token);
231        httpPost.addHeader("accept", "application/json");
232        httpPost.addHeader("Content-Type", "application/json");
233        
234        Map<String, Object> parameters = new HashMap<>();
235        parameters.put(_getIdentifierParameterName(), key);
236        parameters.put("code", value);
237        String json = _jsonUtils.convertObjectToJson(parameters);
238        HttpEntity jsonEntity = new StringEntity(json, ContentType.create("application/json", StandardCharsets.UTF_8));
239        
240        httpPost.setEntity(jsonEntity);
241        
242        try (CloseableHttpResponse response = _httpClient.execute(httpPost))
243        {
244            switch (response.getStatusLine().getStatusCode())
245            {
246                case 200:
247                    break;
248                case 403:
249                    throw new IllegalStateException("The CMS back-office refused the connection");
250                case 500:
251                default:
252                    throw new IllegalStateException("The captchEtat verification server returned an error");
253            }
254            HttpEntity entity = response.getEntity();
255            String responseString = EntityUtils.toString(entity, "UTF-8");
256            return BooleanUtils.toBoolean(responseString);
257        }
258        catch (Exception e)
259        {
260            getLogger().error("Error during request, can't verify captcha", e);
261        }
262        
263        return false;
264    }
265    
266    /**
267     * Get the name of the parameter for identifier when contacting the API
268     * @return the parameter name
269     */
270    protected String _getIdentifierParameterName()
271    {
272        return "uuid";
273    }
274    
275}