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}