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}