001/* 002 * Copyright 2021 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.extrausermgt.users.entraid; 017 018import java.util.ArrayList; 019import java.util.Collection; 020import java.util.Collections; 021import java.util.List; 022import java.util.Map; 023import java.util.concurrent.atomic.AtomicInteger; 024 025import org.apache.commons.lang3.StringUtils; 026 027import org.ametys.core.user.directory.NotUniqueUserException; 028import org.ametys.core.user.directory.StoredUser; 029import org.ametys.core.user.directory.UserDirectory; 030import org.ametys.plugins.core.impl.user.directory.AbstractCachingUserDirectory; 031 032import com.azure.identity.ClientSecretCredential; 033import com.azure.identity.ClientSecretCredentialBuilder; 034import com.microsoft.graph.core.tasks.PageIterator; 035import com.microsoft.graph.models.User; 036import com.microsoft.graph.models.UserCollectionResponse; 037import com.microsoft.graph.models.odataerrors.ODataError; 038import com.microsoft.graph.serviceclient.GraphServiceClient; 039 040/** 041 * {@link UserDirectory} listing users in Entra ID. 042 */ 043public class EntraIDUserDirectory extends AbstractCachingUserDirectory 044{ 045 /** Constant for onPremisesSamAccountName attribute */ 046 public static final String ON_PREMISES_SAM_ACCOUNT_NAME = "onPremisesSamAccountName"; 047 048 private static final String[] __USER_ATTRIBUTES_SELECT = new String[]{"userPrincipalName", "surname", "givenName", "mail", "onPremisesSamAccountName"}; 049 050 private GraphServiceClient _graphClient; 051 private String _filter; 052 private String _loginAttribute; 053 054 @Override 055 public void init(String id, String udModelId, Map<String, Object> paramValues, String label) throws Exception 056 { 057 super.init(id, udModelId, paramValues, label); 058 059 String clientID = (String) paramValues.get("org.ametys.plugins.extrausermgt.users.entraid.appid"); 060 String clientSecret = (String) paramValues.get("org.ametys.plugins.extrausermgt.users.entraid.clientsecret"); 061 String tenant = (String) paramValues.get("org.ametys.plugins.extrausermgt.users.entraid.tenant"); 062 _filter = (String) paramValues.get("org.ametys.plugins.extrausermgt.users.entraid.filter"); 063 _loginAttribute = (String) paramValues.get("org.ametys.plugins.extrausermgt.users.entraid.loginattribute"); 064 065 ClientSecretCredential clientSecretCredential = new ClientSecretCredentialBuilder().clientId(clientID) 066 .clientSecret(clientSecret) 067 .tenantId(tenant) 068 .build(); 069 070 _graphClient = new GraphServiceClient(clientSecretCredential); 071 072 createCaches(); 073 } 074 075 @Override 076 protected String getCacheTypeLabel() 077 { 078 return "EntraID"; 079 } 080 081 public boolean isCaseSensitive() 082 { 083 return false; 084 } 085 086 public Collection<StoredUser> getStoredUsers() 087 { 088 return getStoredUsers(-1, 0, null); 089 } 090 091 public List<StoredUser> getStoredUsers(int count, int offset, Map<String, Object> parameters) 092 { 093 UserCollectionResponse userCollectionResponse = _graphClient.users().get(requestConfiguration -> { 094 requestConfiguration.headers.add("ConsistencyLevel", "eventual"); 095 096 String pattern = parameters != null ? (String) parameters.get("pattern") : null; 097 098 if (StringUtils.isNotEmpty(pattern)) 099 { 100 requestConfiguration.queryParameters.search = "\"givenName:" + pattern + "\" OR \"surname:" + pattern + "\" OR \"userPrincipalName:" + pattern + "\""; 101 } 102 103 if (count > 0 && count < Integer.MAX_VALUE) 104 { 105 requestConfiguration.queryParameters.top = Math.min(count + offset, 999); // try to do only one request to Graph API 106 } 107 108 if (StringUtils.isNotEmpty(_filter)) 109 { 110 requestConfiguration.queryParameters.filter = _filter; 111 112 if (StringUtils.isEmpty(pattern)) 113 { 114 // if we have a filter but no pattern, we have to ask for count, to comply with MSGraph API 115 requestConfiguration.queryParameters.count = true; 116 } 117 } 118 119 requestConfiguration.queryParameters.select = __USER_ATTRIBUTES_SELECT; 120 }); 121 122 List<StoredUser> result = new ArrayList<>(); 123 AtomicInteger offsetCounter = new AtomicInteger(offset); // use AtomicInteger to be able to decrement directly in the below lambda 124 125 try 126 { 127 new PageIterator.Builder<User, UserCollectionResponse>() 128 .client(_graphClient) 129 .collectionPage(userCollectionResponse) 130 .collectionPageFactory(UserCollectionResponse::createFromDiscriminatorValue) 131 .processPageItemCallback(user -> { 132 if (offsetCounter.decrementAndGet() <= 0) 133 { 134 _handleUser(user, result); 135 } 136 137 return count <= 0 || result.size() < count; 138 }) 139 .build() 140 .iterate(); 141 } 142 catch (Exception e) 143 { 144 getLogger().error("Error while fetching users from Entra ID", e); 145 return Collections.emptyList(); 146 } 147 148 return result; 149 } 150 151 /** 152 * Get the user identifier based on the configured login attribute. 153 * @param user The Azure AD user 154 * @return The user identifier (either UserPrincipalName or OnPremisesSamAccountName) 155 */ 156 public String getUserIdentifier(User user) 157 { 158 if (ON_PREMISES_SAM_ACCOUNT_NAME.equals(_loginAttribute)) 159 { 160 String samAccountName = user.getOnPremisesSamAccountName(); 161 if (StringUtils.isNotBlank(samAccountName)) 162 { 163 return samAccountName; 164 } 165 else 166 { 167 // Fallback to UserPrincipalName if OnPremisesSamAccountName is not available 168 getLogger().debug("OnPremisesSamAccountName not available for user {}, falling back to UserPrincipalName", user.getUserPrincipalName()); 169 return user.getUserPrincipalName(); 170 } 171 } 172 else 173 { 174 // Default to UserPrincipalName 175 return user.getUserPrincipalName(); 176 } 177 } 178 179 private void _handleUser(User user, List<StoredUser> storedUsers) 180 { 181 String userIdentifier = getUserIdentifier(user); 182 StoredUser storedUser = new StoredUser(userIdentifier, user.getSurname(), user.getGivenName(), user.getMail()); 183 storedUsers.add(storedUser); 184 185 if (isCachingEnabled()) 186 { 187 getCacheByLogin().put(storedUser.getIdentifier(), storedUser); 188 } 189 } 190 191 public StoredUser getStoredUser(String login) 192 { 193 if (isCachingEnabled() && getCacheByLogin().hasKey(login)) 194 { 195 StoredUser storedUser = getCacheByLogin().get(login); 196 return storedUser; 197 } 198 199 StoredUser storedUser = null; 200 try 201 { 202 User user = null; 203 204 // Use different query strategies based on login attribute configuration 205 if (ON_PREMISES_SAM_ACCOUNT_NAME.equals(_loginAttribute)) 206 { 207 // First, try to search by onPremisesSamAccountName filter 208 List<User> users = _graphClient.users().get(requestConfiguration -> { 209 requestConfiguration.headers.add("ConsistencyLevel", "eventual"); 210 requestConfiguration.queryParameters.filter = "onPremisesSamAccountName eq '" + login + "'"; 211 requestConfiguration.queryParameters.count = true; 212 requestConfiguration.queryParameters.select = __USER_ATTRIBUTES_SELECT; 213 }).getValue(); 214 215 if (!users.isEmpty()) 216 { 217 user = users.get(0); 218 } 219 else 220 { 221 // If not found by SAM, the login might be a UPN (fallback case) 222 // Try to search by UserPrincipalName 223 try 224 { 225 user = _graphClient.users().byUserId(login).get(requestConfiguration -> { 226 requestConfiguration.queryParameters.select = __USER_ATTRIBUTES_SELECT; 227 }); 228 229 // Verify that this user actually doesn't have a SAM account name 230 // (to ensure it's a legitimate fallback case) 231 if (user != null && StringUtils.isNotBlank(user.getOnPremisesSamAccountName())) 232 { 233 // This user has a SAM account name, so the login should have been the SAM, not the UPN 234 // This means the login provided doesn't match our configuration 235 user = null; 236 } 237 } 238 catch (Exception e) 239 { 240 // User not found by UPN either, user is null 241 getLogger().debug("User '{}' not found by SAM or UPN", login, e); 242 } 243 } 244 } 245 else 246 { 247 // For UserPrincipalName, we can use the direct byUserId method 248 user = _graphClient.users().byUserId(login).get(requestConfiguration -> { 249 requestConfiguration.queryParameters.select = __USER_ATTRIBUTES_SELECT; 250 }); 251 } 252 253 if (user != null) 254 { 255 String userIdentifier = getUserIdentifier(user); 256 storedUser = new StoredUser(userIdentifier, user.getSurname(), user.getGivenName(), user.getMail()); 257 258 if (isCachingEnabled()) 259 { 260 getCacheByLogin().put(storedUser.getIdentifier(), storedUser); 261 } 262 } 263 } 264 catch (ODataError e) 265 { 266 // Handle ODataError specifically, which may indicate a not found error 267 if (e.getResponseStatusCode() == 404) 268 { 269 getLogger().debug("User '{}' not found in EntraID", login); 270 } 271 else 272 { 273 getLogger().warn("Unable to retrieve user '{}' from EntraID", login, e); 274 } 275 } 276 catch (Exception e) 277 { 278 getLogger().warn("Unable to retrieve user '{}' from EntraID", login, e); 279 } 280 281 return storedUser; 282 } 283 284 /* 285 * As we do not know how to search for email in a "case insensitive" way, we also fill the cache "case sensitively" 286 */ 287 public StoredUser getStoredUserByEmail(String email) throws NotUniqueUserException 288 { 289 if (StringUtils.isBlank(email)) 290 { 291 return null; 292 } 293 294 if (isCachingEnabled() && getCacheByMail().hasKey(email)) 295 { 296 StoredUser storedUser = getCacheByMail().get(email); 297 return storedUser; 298 } 299 300 List<User> users = _graphClient.users().get(requestConfiguration -> { 301 requestConfiguration.headers.add("ConsistencyLevel", "eventual"); 302 requestConfiguration.queryParameters.filter = "mail eq '" + email + "'"; 303 requestConfiguration.queryParameters.select = __USER_ATTRIBUTES_SELECT; 304 }).getValue(); 305 306 if (users.size() == 1) 307 { 308 User u = users.get(0); 309 String userIdentifier = getUserIdentifier(u); 310 StoredUser storedUser = new StoredUser(userIdentifier, u.getSurname(), u.getGivenName(), u.getMail()); 311 312 if (isCachingEnabled()) 313 { 314 getCacheByMail().put(storedUser.getEmail(), storedUser); 315 } 316 317 return storedUser; 318 } 319 else if (users.isEmpty()) 320 { 321 return null; 322 } 323 else 324 { 325 throw new NotUniqueUserException("Find " + users.size() + " users matching the email " + email); 326 } 327 } 328 329 public CredentialsResult checkCredentials(String login, String password) 330 { 331 throw new UnsupportedOperationException("The EntraIDUserDirectory cannot authenticate users"); 332 } 333}