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}