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 Collection<StoredUser> getStoredUsers() 082 { 083 return getStoredUsers(-1, 0, null); 084 } 085 086 public List<StoredUser> getStoredUsers(int count, int offset, Map<String, Object> parameters) 087 { 088 UserCollectionResponse userCollectionResponse = _graphClient.users().get(requestConfiguration -> { 089 requestConfiguration.headers.add("ConsistencyLevel", "eventual"); 090 091 String pattern = parameters != null ? (String) parameters.get("pattern") : null; 092 093 if (StringUtils.isNotEmpty(pattern)) 094 { 095 requestConfiguration.queryParameters.search = "\"givenName:" + pattern + "\" OR \"surname:" + pattern + "\" OR \"userPrincipalName:" + pattern + "\""; 096 } 097 098 if (count > 0 && count < Integer.MAX_VALUE) 099 { 100 requestConfiguration.queryParameters.top = Math.min(count + offset, 999); // try to do only one request to Graph API 101 } 102 103 if (StringUtils.isNotEmpty(_filter)) 104 { 105 requestConfiguration.queryParameters.filter = _filter; 106 107 if (StringUtils.isEmpty(pattern)) 108 { 109 // if we have a filter but no pattern, we have to ask for count, to comply with MSGraph API 110 requestConfiguration.queryParameters.count = true; 111 } 112 } 113 114 requestConfiguration.queryParameters.select = __USER_ATTRIBUTES_SELECT; 115 }); 116 117 List<StoredUser> result = new ArrayList<>(); 118 AtomicInteger offsetCounter = new AtomicInteger(offset); // use AtomicInteger to be able to decrement directly in the below lambda 119 120 try 121 { 122 new PageIterator.Builder<User, UserCollectionResponse>() 123 .client(_graphClient) 124 .collectionPage(userCollectionResponse) 125 .collectionPageFactory(UserCollectionResponse::createFromDiscriminatorValue) 126 .processPageItemCallback(user -> { 127 if (offsetCounter.decrementAndGet() <= 0) 128 { 129 _handleUser(user, result); 130 } 131 132 return count <= 0 || result.size() < count; 133 }) 134 .build() 135 .iterate(); 136 } 137 catch (Exception e) 138 { 139 getLogger().error("Error while fetching users from Entra ID", e); 140 return Collections.emptyList(); 141 } 142 143 return result; 144 } 145 146 /** 147 * Get the user identifier based on the configured login attribute. 148 * @param user The Azure AD user 149 * @return The user identifier (either UserPrincipalName or OnPremisesSamAccountName) 150 */ 151 public String getUserIdentifier(User user) 152 { 153 if (ON_PREMISES_SAM_ACCOUNT_NAME.equals(_loginAttribute)) 154 { 155 String samAccountName = user.getOnPremisesSamAccountName(); 156 if (StringUtils.isNotBlank(samAccountName)) 157 { 158 return samAccountName; 159 } 160 else 161 { 162 // Fallback to UserPrincipalName if OnPremisesSamAccountName is not available 163 getLogger().debug("OnPremisesSamAccountName not available for user {}, falling back to UserPrincipalName", user.getUserPrincipalName()); 164 return user.getUserPrincipalName(); 165 } 166 } 167 else 168 { 169 // Default to UserPrincipalName 170 return user.getUserPrincipalName(); 171 } 172 } 173 174 private void _handleUser(User user, List<StoredUser> storedUsers) 175 { 176 String userIdentifier = getUserIdentifier(user); 177 StoredUser storedUser = new StoredUser(userIdentifier, user.getSurname(), user.getGivenName(), user.getMail()); 178 storedUsers.add(storedUser); 179 180 if (isCachingEnabled()) 181 { 182 getCacheByLogin().put(storedUser.getIdentifier(), storedUser); 183 } 184 } 185 186 public StoredUser getStoredUser(String login) 187 { 188 if (isCachingEnabled() && getCacheByLogin().hasKey(login)) 189 { 190 StoredUser storedUser = getCacheByLogin().get(login); 191 return storedUser; 192 } 193 194 StoredUser storedUser = null; 195 try 196 { 197 User user = null; 198 199 // Use different query strategies based on login attribute configuration 200 if (ON_PREMISES_SAM_ACCOUNT_NAME.equals(_loginAttribute)) 201 { 202 // First, try to search by onPremisesSamAccountName filter 203 List<User> users = _graphClient.users().get(requestConfiguration -> { 204 requestConfiguration.headers.add("ConsistencyLevel", "eventual"); 205 requestConfiguration.queryParameters.filter = "onPremisesSamAccountName eq '" + login + "'"; 206 requestConfiguration.queryParameters.count = true; 207 requestConfiguration.queryParameters.select = __USER_ATTRIBUTES_SELECT; 208 }).getValue(); 209 210 if (!users.isEmpty()) 211 { 212 user = users.get(0); 213 } 214 else 215 { 216 // If not found by SAM, the login might be a UPN (fallback case) 217 // Try to search by UserPrincipalName 218 try 219 { 220 user = _graphClient.users().byUserId(login).get(requestConfiguration -> { 221 requestConfiguration.queryParameters.select = __USER_ATTRIBUTES_SELECT; 222 }); 223 224 // Verify that this user actually doesn't have a SAM account name 225 // (to ensure it's a legitimate fallback case) 226 if (user != null && StringUtils.isNotBlank(user.getOnPremisesSamAccountName())) 227 { 228 // This user has a SAM account name, so the login should have been the SAM, not the UPN 229 // This means the login provided doesn't match our configuration 230 user = null; 231 } 232 } 233 catch (Exception e) 234 { 235 // User not found by UPN either, user is null 236 getLogger().debug("User '{}' not found by SAM or UPN", login, e); 237 } 238 } 239 } 240 else 241 { 242 // For UserPrincipalName, we can use the direct byUserId method 243 user = _graphClient.users().byUserId(login).get(requestConfiguration -> { 244 requestConfiguration.queryParameters.select = __USER_ATTRIBUTES_SELECT; 245 }); 246 } 247 248 if (user != null) 249 { 250 String userIdentifier = getUserIdentifier(user); 251 storedUser = new StoredUser(userIdentifier, user.getSurname(), user.getGivenName(), user.getMail()); 252 253 if (isCachingEnabled()) 254 { 255 getCacheByLogin().put(storedUser.getIdentifier(), storedUser); 256 } 257 } 258 } 259 catch (ODataError e) 260 { 261 // Handle ODataError specifically, which may indicate a not found error 262 if (e.getResponseStatusCode() == 404) 263 { 264 getLogger().debug("User '{}' not found in EntraID", login); 265 } 266 else 267 { 268 getLogger().warn("Unable to retrieve user '{}' from EntraID", login, e); 269 } 270 } 271 catch (Exception e) 272 { 273 getLogger().warn("Unable to retrieve user '{}' from EntraID", login, e); 274 } 275 276 return storedUser; 277 } 278 279 /* 280 * As we do not know how to search for email in a "case insensitive" way, we also fill the cache "case sensitively" 281 */ 282 public StoredUser getStoredUserByEmail(String email) throws NotUniqueUserException 283 { 284 if (StringUtils.isBlank(email)) 285 { 286 return null; 287 } 288 289 if (isCachingEnabled() && getCacheByMail().hasKey(email)) 290 { 291 StoredUser storedUser = getCacheByMail().get(email); 292 return storedUser; 293 } 294 295 List<User> users = _graphClient.users().get(requestConfiguration -> { 296 requestConfiguration.headers.add("ConsistencyLevel", "eventual"); 297 requestConfiguration.queryParameters.filter = "mail eq '" + email + "'"; 298 requestConfiguration.queryParameters.select = __USER_ATTRIBUTES_SELECT; 299 }).getValue(); 300 301 if (users.size() == 1) 302 { 303 User u = users.get(0); 304 String userIdentifier = getUserIdentifier(u); 305 StoredUser storedUser = new StoredUser(userIdentifier, u.getSurname(), u.getGivenName(), u.getMail()); 306 307 if (isCachingEnabled()) 308 { 309 getCacheByMail().put(storedUser.getEmail(), storedUser); 310 } 311 312 return storedUser; 313 } 314 else if (users.isEmpty()) 315 { 316 return null; 317 } 318 else 319 { 320 throw new NotUniqueUserException("Find " + users.size() + " users matching the email " + email); 321 } 322 } 323 324 public CredentialsResult checkCredentials(String login, String password) 325 { 326 throw new UnsupportedOperationException("The EntraIDUserDirectory cannot authenticate users"); 327 } 328}