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