001package com.nimbusds.openid.connect.provider.spi.claims.ldap; 002 003 004import java.io.InputStream; 005import java.nio.charset.Charset; 006import java.util.*; 007 008import org.apache.commons.io.IOUtils; 009 010import org.apache.logging.log4j.LogManager; 011import org.apache.logging.log4j.Logger; 012 013import net.minidev.json.JSONObject; 014 015import com.unboundid.ldap.sdk.Entry; 016import com.unboundid.ldap.sdk.LDAPConnectionPool; 017import com.unboundid.ldap.sdk.SearchResult; 018 019import com.nimbusds.common.ldap.AttributeMapper; 020import com.nimbusds.common.ldap.LDAPConnectionPoolFactory; 021 022import com.nimbusds.langtag.LangTag; 023 024import com.nimbusds.oauth2.sdk.id.Subject; 025import com.nimbusds.oauth2.sdk.util.JSONObjectUtils; 026import com.nimbusds.openid.connect.sdk.claims.UserInfo; 027 028import com.nimbusds.openid.connect.provider.spi.InitContext; 029import com.nimbusds.openid.connect.provider.spi.claims.ClaimUtils; 030import com.nimbusds.openid.connect.provider.spi.claims.ClaimsSource; 031 032 033/** 034 * LDAP connector for retrieving OpenID Connect UserInfo claims. 035 */ 036public class LDAPClaimsSource implements ClaimsSource { 037 038 039 /** 040 * The configuration file path. 041 */ 042 public static final String CONFIG_FILE_PATH = "/WEB-INF/ldapClaimsSource.properties"; 043 044 045 /** 046 * The LDAP claims map file path. 047 */ 048 public static final String MAP_FILE_PATH = "/WEB-INF/ldapClaimsMap.json"; 049 050 051 /** 052 * The LDAP connector configuration. 053 */ 054 private Configuration config; 055 056 057 /** 058 * The supported UserInfo claims map. Allows for mapping of complex top 059 * level claims, such as "address" to their sub-claims, while simple 060 * claims map one-to-one. 061 */ 062 private Map<String,List<String>> claimsMap; 063 064 065 /** 066 * The UserInfo attribute mapper. 067 */ 068 private AttributeMapper attributeMapper; 069 070 071 /** 072 * The LDAP connection pool. 073 */ 074 private LDAPConnectionPool ldapConnPool; 075 076 077 /** 078 * The logger. 079 */ 080 private final Logger log = LogManager.getLogger("MAIN"); 081 082 083 /** 084 * Creates a new LDAP claims source. It must be {@link #init 085 * initialised} before it can be used. 086 */ 087 public LDAPClaimsSource() { } 088 089 090 /** 091 * Loads the configuration. 092 * 093 * @param initContext The initialisation context. Must not be 094 * {@code null}. 095 * 096 * @return The configuration. 097 * 098 * @throws Exception If loading failed. 099 */ 100 private static Configuration loadConfiguration(final InitContext initContext) 101 throws Exception { 102 103 InputStream inputStream = initContext.getResourceAsStream(CONFIG_FILE_PATH); 104 105 if (inputStream == null) { 106 throw new Exception("Couldn't find LDAP claims source configuration file: " + CONFIG_FILE_PATH); 107 } 108 109 Properties props = new Properties(); 110 props.load(inputStream); 111 112 return new Configuration(props); 113 } 114 115 116 /** 117 * Loads the LDAP attribute map. 118 * 119 * @param initContext The initialisation context. Must not be 120 * {@code null}. 121 * 122 * @return The LDAP attribute map. 123 * 124 * @throws Exception If loading failed. 125 */ 126 private static Map<String,Object> loadLDAPAttributeMap(final InitContext initContext) 127 throws Exception { 128 129 InputStream inputStream = initContext.getResourceAsStream(MAP_FILE_PATH); 130 131 if (inputStream == null) { 132 throw new Exception("Couldn't find LDAP claims map file: " + MAP_FILE_PATH); 133 } 134 135 try { 136 String jsonText = IOUtils.toString(inputStream, Charset.forName("UTF-8")); 137 return JSONObjectUtils.parseJSONObject(jsonText); 138 139 } catch (Exception e) { 140 141 throw new Exception("Couldn't load LDAP claims map: " + e.getMessage(), e); 142 } 143 } 144 145 146 /** 147 * Composes the claims map from the specified LDAP attributes map. 148 * 149 * @param attrMap The LDAP attribute map. Must not be {@code null}. 150 * 151 * @return The claims map. 152 */ 153 private static Map<String,List<String>> composeClaimsMap(final Map<String,Object> attrMap) { 154 155 // Derive the supported UserInfo claims map 156 Map<String,List<String>> claimsMap = new HashMap<>(); 157 158 for (String key: attrMap.keySet()) { 159 160 String[] parts = key.split("\\.", 2); 161 162 List<String> subClaims = claimsMap.get(parts[0]); 163 164 if (subClaims == null) { 165 subClaims = new LinkedList<>(); 166 } 167 168 subClaims.add(key); 169 170 claimsMap.put(parts[0], subClaims); 171 } 172 173 return claimsMap; 174 } 175 176 177 @Override 178 public void init(final InitContext initContext) 179 throws Exception { 180 181 log.info("Initializing LDAP claims source..."); 182 183 config = loadConfiguration(initContext); 184 185 config.log(); 186 187 if (! config.enable) { 188 // stop initialisation 189 return; 190 } 191 192 // Load the raw LDAP attribute map 193 Map<String,Object> ldapAttributeMap = loadLDAPAttributeMap(initContext); 194 attributeMapper = new AttributeMapper(ldapAttributeMap); 195 196 if (attributeMapper.getLDAPAttributeName("sub") == null) { 197 throw new Exception("Missing LDAP attribute mapping for \"sub\" claim"); 198 } 199 200 // Compose the final claims map 201 claimsMap = composeClaimsMap(ldapAttributeMap); 202 203 LDAPConnectionPoolFactory factory = new LDAPConnectionPoolFactory(config.server, 204 config.customTrustStore, 205 config.customKeyStore, 206 config.directory.user); 207 208 try { 209 ldapConnPool = factory.createLDAPConnectionPool(); 210 211 } catch (Exception e) { 212 213 // java.security.KeyStoreException 214 // java.security.GeneralSecurityException 215 // com.unboundid.ldap.sdk.LDAPException 216 217 throw new Exception("Couldn't create LDAP connection pool: " + e.getMessage(), e); 218 } 219 220 ldapConnPool.setConnectionPoolName("userinfo-store"); 221 } 222 223 224 @Override 225 public boolean isEnabled() { 226 227 return config.enable; 228 } 229 230 231 @Override 232 public Set<String> supportedClaims() { 233 234 if (! config.enable) { 235 // Empty set 236 return Collections.unmodifiableSet(new HashSet<String>()); 237 } 238 239 return Collections.unmodifiableSet(claimsMap.keySet()); 240 } 241 242 243 /** 244 * Resolves the individual requested claims from the specified 245 * requested claims and preferred locales. 246 * 247 * @param claims The requested claims. May contain optional 248 * language tags. Must not be {@code null}. 249 * @param claimsLocales The preferred locales, {@code null} if not 250 * specified. 251 * 252 * @return The resolved individual requested claims. 253 */ 254 protected List<String> resolveRequestedClaims(final Set<String> claims, 255 final List<LangTag> claimsLocales) { 256 257 // Use set to ensure no duplicates get into the collection 258 Set<String> individualClaims = new HashSet<>(); 259 260 for (String claim: claims) { 261 262 // Check if the claim is supported and if any sub-claims 263 // are associated with it (e.g. for UserInfo address) 264 List<String> claimsList = claimsMap.get(claim); 265 266 if (claimsList == null) { 267 // claim not supported 268 continue; 269 } 270 271 individualClaims.addAll(claimsList); 272 } 273 274 // Apply the preferred language tags if any 275 individualClaims = ClaimUtils.applyLangTags(individualClaims, claimsLocales); 276 277 return new ArrayList<>(individualClaims); 278 } 279 280 281 @Override 282 public UserInfo getClaims(final Subject subject, 283 final Set<String> claims, 284 final List<LangTag> claimsLocales) 285 throws Exception { 286 287 if (! config.enable) 288 return null; 289 290 // Compose search filter from the cofigured template 291 String filter = config.directory.filter.apply(subject.getValue()); 292 293 // Resolve the individual requested claims 294 List<String> claimsToRequest = resolveRequestedClaims(claims, claimsLocales); 295 296 // Map OIDC claim names to LDAP attribute names 297 List<String> ldapAttrs = attributeMapper.getLDAPAttributeNames(claimsToRequest); 298 299 // Do LDAP search 300 SearchResult searchResult; 301 302 try { 303 searchResult = ldapConnPool.search( 304 config.directory.baseDN.toString(), 305 config.directory.scope, 306 filter, 307 ldapAttrs.toArray(new String[0])); 308 309 } catch (Exception e) { 310 311 // LDAPException 312 throw new Exception("Couldn't get UserInfo for subject \"" + subject + "\": " + e.getMessage(), e); 313 } 314 315 316 // Get matches count 317 final int entryCount = searchResult.getEntryCount(); 318 319 if (entryCount == 0) { 320 // Nothing found 321 return null; 322 } 323 324 if (entryCount > 1) { 325 // More than one entry found 326 throw new Exception("Found " + entryCount + " entries for subject \"" + subject + "\""); 327 } 328 329 330 // Process user entry 331 Entry entry = searchResult.getSearchEntries().get(0); 332 333 334 Map<String,Object> entryObject = attributeMapper.transform(entry); 335 336 337 // Remove unrequested attributes that have got into the entry 338 // See issue #2 339 List<String> unwantedClaims = new ArrayList<>(); 340 341 for (String claimName: entryObject.keySet()) { 342 343 if (! claims.contains(claimName)) 344 unwantedClaims.add(claimName); 345 } 346 347 for (String claimToRemove: unwantedClaims) { 348 entryObject.remove(claimToRemove); 349 } 350 351 // Append mandatory "sub" claim 352 entryObject.put("sub", subject.getValue()); 353 354 try { 355 return new UserInfo(new JSONObject(entryObject)); 356 357 } catch (IllegalArgumentException e) { 358 359 throw new Exception("Couldn't create UserInfo object: " + e.getMessage(), e); 360 } 361 } 362 363 364 @Override 365 public void shutdown() 366 throws Exception { 367 368 if (ldapConnPool != null) { 369 // Close the LDAP connection pool 370 ldapConnPool.close(); 371 } 372 } 373}