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}