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