001/******************************************************************************* 002 * Copyright 2017 The MIT Internet Trust Consortium 003 * 004 * Portions copyright 2011-2013 The MITRE Corporation 005 * 006 * Licensed under the Apache License, Version 2.0 (the "License"); 007 * you may not use this file except in compliance with the License. 008 * You may obtain a copy of the License at 009 * 010 * http://www.apache.org/licenses/LICENSE-2.0 011 * 012 * Unless required by applicable law or agreed to in writing, software 013 * distributed under the License is distributed on an "AS IS" BASIS, 014 * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 015 * See the License for the specific language governing permissions and 016 * limitations under the License. 017 *******************************************************************************/ 018/** 019 * 020 */ 021package org.mitre.openid.connect.client.service.impl; 022 023import java.util.HashSet; 024import java.util.Set; 025import java.util.concurrent.ExecutionException; 026 027import javax.servlet.http.HttpServletRequest; 028 029import org.apache.http.client.HttpClient; 030import org.apache.http.client.utils.URIBuilder; 031import org.apache.http.impl.client.HttpClientBuilder; 032import org.mitre.discovery.util.WebfingerURLNormalizer; 033import org.mitre.openid.connect.client.model.IssuerServiceResponse; 034import org.mitre.openid.connect.client.service.IssuerService; 035import org.slf4j.Logger; 036import org.slf4j.LoggerFactory; 037import org.springframework.http.client.HttpComponentsClientHttpRequestFactory; 038import org.springframework.security.authentication.AuthenticationServiceException; 039import org.springframework.web.client.RestClientException; 040import org.springframework.web.client.RestTemplate; 041import org.springframework.web.util.UriComponents; 042 043import com.google.common.base.Strings; 044import com.google.common.cache.CacheBuilder; 045import com.google.common.cache.CacheLoader; 046import com.google.common.cache.LoadingCache; 047import com.google.common.util.concurrent.UncheckedExecutionException; 048import com.google.gson.JsonArray; 049import com.google.gson.JsonElement; 050import com.google.gson.JsonObject; 051import com.google.gson.JsonParseException; 052import com.google.gson.JsonParser; 053 054/** 055 * Use Webfinger to discover the appropriate issuer for a user-given input string. 056 * @author jricher 057 * 058 */ 059public class WebfingerIssuerService implements IssuerService { 060 061 /** 062 * Logger for this class 063 */ 064 private static final Logger logger = LoggerFactory.getLogger(WebfingerIssuerService.class); 065 066 // map of user input -> issuer, loaded dynamically from webfinger discover 067 private LoadingCache<String, LoadingResult> issuers; 068 069 // private data shuttle class to get back two bits of info from the cache loader 070 private class LoadingResult { 071 public String loginHint; 072 public String issuer; 073 public LoadingResult(String loginHint, String issuer) { 074 this.loginHint = loginHint; 075 this.issuer = issuer; 076 } 077 } 078 079 private Set<String> whitelist = new HashSet<>(); 080 private Set<String> blacklist = new HashSet<>(); 081 082 /** 083 * Name of the incoming parameter to check for discovery purposes. 084 */ 085 private String parameterName = "identifier"; 086 087 /** 088 * URL of the page to forward to if no identifier is given. 089 */ 090 private String loginPageUrl; 091 092 /** 093 * Strict enfocement of "https" 094 */ 095 private boolean forceHttps = true; 096 097 public WebfingerIssuerService() { 098 this(HttpClientBuilder.create().useSystemProperties().build()); 099 } 100 101 public WebfingerIssuerService(HttpClient httpClient) { 102 issuers = CacheBuilder.newBuilder().build(new WebfingerIssuerFetcher(httpClient)); 103 } 104 105 /* (non-Javadoc) 106 * @see org.mitre.openid.connect.client.service.IssuerService#getIssuer(javax.servlet.http.HttpServletRequest) 107 */ 108 @Override 109 public IssuerServiceResponse getIssuer(HttpServletRequest request) { 110 111 String identifier = request.getParameter(parameterName); 112 if (!Strings.isNullOrEmpty(identifier)) { 113 try { 114 LoadingResult lr = issuers.get(identifier); 115 if (!whitelist.isEmpty() && !whitelist.contains(lr.issuer)) { 116 throw new AuthenticationServiceException("Whitelist was nonempty, issuer was not in whitelist: " + lr.issuer); 117 } 118 119 if (blacklist.contains(lr.issuer)) { 120 throw new AuthenticationServiceException("Issuer was in blacklist: " + lr.issuer); 121 } 122 123 return new IssuerServiceResponse(lr.issuer, lr.loginHint, request.getParameter("target_link_uri")); 124 } catch (UncheckedExecutionException | ExecutionException e) { 125 logger.warn("Issue fetching issuer for user input: " + identifier + ": " + e.getMessage()); 126 return null; 127 } 128 129 } else { 130 logger.warn("No user input given, directing to login page: " + loginPageUrl); 131 return new IssuerServiceResponse(loginPageUrl); 132 } 133 } 134 135 /** 136 * @return the parameterName 137 */ 138 public String getParameterName() { 139 return parameterName; 140 } 141 142 /** 143 * @param parameterName the parameterName to set 144 */ 145 public void setParameterName(String parameterName) { 146 this.parameterName = parameterName; 147 } 148 149 150 /** 151 * @return the loginPageUrl 152 */ 153 public String getLoginPageUrl() { 154 return loginPageUrl; 155 } 156 157 /** 158 * @param loginPageUrl the loginPageUrl to set 159 */ 160 public void setLoginPageUrl(String loginPageUrl) { 161 this.loginPageUrl = loginPageUrl; 162 } 163 164 /** 165 * @return the whitelist 166 */ 167 public Set<String> getWhitelist() { 168 return whitelist; 169 } 170 171 /** 172 * @param whitelist the whitelist to set 173 */ 174 public void setWhitelist(Set<String> whitelist) { 175 this.whitelist = whitelist; 176 } 177 178 /** 179 * @return the blacklist 180 */ 181 public Set<String> getBlacklist() { 182 return blacklist; 183 } 184 185 /** 186 * @param blacklist the blacklist to set 187 */ 188 public void setBlacklist(Set<String> blacklist) { 189 this.blacklist = blacklist; 190 } 191 192 /** 193 * @return the forceHttps 194 */ 195 public boolean isForceHttps() { 196 return forceHttps; 197 } 198 199 /** 200 * @param forceHttps the forceHttps to set 201 */ 202 public void setForceHttps(boolean forceHttps) { 203 this.forceHttps = forceHttps; 204 } 205 206 /** 207 * @author jricher 208 * 209 */ 210 private class WebfingerIssuerFetcher extends CacheLoader<String, LoadingResult> { 211 private HttpComponentsClientHttpRequestFactory httpFactory; 212 private JsonParser parser = new JsonParser(); 213 214 WebfingerIssuerFetcher(HttpClient httpClient) { 215 this.httpFactory = new HttpComponentsClientHttpRequestFactory(httpClient); 216 } 217 218 @Override 219 public LoadingResult load(String identifier) throws Exception { 220 221 UriComponents key = WebfingerURLNormalizer.normalizeResource(identifier); 222 223 RestTemplate restTemplate = new RestTemplate(httpFactory); 224 // construct the URL to go to 225 226 String scheme = key.getScheme(); 227 228 // preserving http scheme is strictly for demo system use only. 229 if (!Strings.isNullOrEmpty(scheme) &&scheme.equals("http")) { 230 if (forceHttps) { 231 throw new IllegalArgumentException("Scheme must not be 'http'"); 232 } else { 233 logger.warn("Webfinger endpoint MUST use the https URI scheme, overriding by configuration"); 234 scheme = "http://"; // add on colon and slashes. 235 } 236 } else { 237 // otherwise we don't know the scheme, assume HTTPS 238 scheme = "https://"; 239 } 240 241 // do a webfinger lookup 242 URIBuilder builder = new URIBuilder(scheme 243 + key.getHost() 244 + (key.getPort() >= 0 ? ":" + key.getPort() : "") 245 + Strings.nullToEmpty(key.getPath()) 246 + "/.well-known/webfinger" 247 + (Strings.isNullOrEmpty(key.getQuery()) ? "" : "?" + key.getQuery()) 248 ); 249 builder.addParameter("resource", identifier); 250 builder.addParameter("rel", "http://openid.net/specs/connect/1.0/issuer"); 251 252 try { 253 254 // do the fetch 255 logger.info("Loading: " + builder.toString()); 256 String webfingerResponse = restTemplate.getForObject(builder.build(), String.class); 257 258 JsonElement json = parser.parse(webfingerResponse); 259 260 if (json != null && json.isJsonObject()) { 261 // find the issuer 262 JsonArray links = json.getAsJsonObject().get("links").getAsJsonArray(); 263 for (JsonElement link : links) { 264 if (link.isJsonObject()) { 265 JsonObject linkObj = link.getAsJsonObject(); 266 if (linkObj.has("href") 267 && linkObj.has("rel") 268 && linkObj.get("rel").getAsString().equals("http://openid.net/specs/connect/1.0/issuer")) { 269 270 // we found the issuer, return it 271 String href = linkObj.get("href").getAsString(); 272 273 if (identifier.equals(href) 274 || identifier.startsWith("http")) { 275 // try to avoid sending a URL as the login hint 276 return new LoadingResult(null, href); 277 } else { 278 // otherwise pass back whatever the user typed as a login hint 279 return new LoadingResult(identifier, href); 280 } 281 } 282 } 283 } 284 } 285 } catch (JsonParseException | RestClientException e) { 286 logger.warn("Failure in fetching webfinger input", e.getMessage()); 287 } 288 289 // we couldn't find it! 290 291 if (key.getScheme().equals("http") || key.getScheme().equals("https")) { 292 // if it looks like HTTP then punt: return the input, hope for the best 293 logger.warn("Returning normalized input string as issuer, hoping for the best: " + identifier); 294 return new LoadingResult(null, identifier); 295 } else { 296 // if it's not HTTP, give up 297 logger.warn("Couldn't find issuer: " + identifier); 298 throw new IllegalArgumentException(); 299 } 300 301 } 302 303 } 304 305}