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
073    @Override
074    protected String getCacheTypeLabel()
075    {
076        return "EntraID";
077    }
078    
079    public boolean isCaseSensitive()
080    {
081        return false;
082    }
083
084    public Collection<StoredUser> getStoredUsers()
085    {
086        return getStoredUsers(-1, 0, null);
087    }
088
089    public List<StoredUser> getStoredUsers(int count, int offset, Map<String, Object> parameters)
090    {
091        UserCollectionResponse userCollectionResponse = _graphClient.users().get(requestConfiguration -> {
092            requestConfiguration.headers.add("ConsistencyLevel", "eventual");
093            
094            String pattern = parameters != null ? (String) parameters.get("pattern") : null;
095            
096            if (StringUtils.isNotEmpty(pattern))
097            {
098                requestConfiguration.queryParameters.search = "\"givenName:" + pattern + "\" OR \"surname:" + pattern + "\" OR \"userPrincipalName:" + pattern + "\"";
099            }
100            
101            if (count > 0 && count < Integer.MAX_VALUE)
102            {
103                requestConfiguration.queryParameters.top = Math.min(count + offset, 999); // try to do only one request to Graph API
104            }
105            
106            if (StringUtils.isNotEmpty(_filter))
107            {
108                requestConfiguration.queryParameters.filter = _filter;
109                
110                if (StringUtils.isEmpty(pattern))
111                {
112                    // if we have a filter but no pattern, we have to ask for count, to comply with MSGraph API
113                    requestConfiguration.queryParameters.count = true;
114                }
115            }
116            
117            requestConfiguration.queryParameters.select = __USER_ATTRIBUTES_SELECT;
118        });
119
120        List<StoredUser> result = new ArrayList<>();
121        AtomicInteger offsetCounter = new AtomicInteger(offset); // use AtomicInteger to be able to decrement directly in the below lambda
122        
123        try
124        {
125            new PageIterator.Builder<User, UserCollectionResponse>()
126                            .client(_graphClient)
127                            .collectionPage(userCollectionResponse)
128                            .collectionPageFactory(UserCollectionResponse::createFromDiscriminatorValue)
129                            .processPageItemCallback(user -> {
130                                if (offsetCounter.decrementAndGet() <= 0)
131                                {
132                                    _handleUser(user, result);
133                                }
134                                
135                                return count <= 0 || result.size() < count;
136                            })
137                            .build()
138                            .iterate();
139        }
140        catch (Exception e)
141        {
142            getLogger().error("Error while fetching users from Entra ID", e);
143            return Collections.emptyList();
144        }
145        
146        return result;
147    }
148    
149    /**
150     * Get the user identifier based on the configured login attribute.
151     * @param user The Azure AD user
152     * @return The user identifier (either UserPrincipalName or OnPremisesSamAccountName)
153     */
154    public String getUserIdentifier(User user)
155    {
156        if (ON_PREMISES_SAM_ACCOUNT_NAME.equals(_loginAttribute))
157        {
158            String samAccountName = user.getOnPremisesSamAccountName();
159            if (StringUtils.isNotBlank(samAccountName))
160            {
161                return samAccountName;
162            }
163            else
164            {
165                // Fallback to UserPrincipalName if OnPremisesSamAccountName is not available
166                getLogger().debug("OnPremisesSamAccountName not available for user {}, falling back to UserPrincipalName", user.getUserPrincipalName());
167                return user.getUserPrincipalName();
168            }
169        }
170        else
171        {
172            // Default to UserPrincipalName
173            return user.getUserPrincipalName();
174        }
175    }
176    
177    private void _handleUser(User user, List<StoredUser> storedUsers)
178    {
179        String userIdentifier = getUserIdentifier(user);
180        StoredUser storedUser = new StoredUser(userIdentifier, user.getSurname(), user.getGivenName(), user.getMail());
181        storedUsers.add(storedUser);
182        
183        getCacheByLogin().put(storedUser.getIdentifier(), storedUser);
184    }
185
186    public StoredUser getStoredUser(String login)
187    {
188        if (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                getCacheByLogin().put(storedUser.getIdentifier(), storedUser);
254            }
255        }
256        catch (ODataError e)
257        {
258            // Handle ODataError specifically, which may indicate a not found error
259            if (e.getResponseStatusCode() == 404)
260            {
261                getLogger().debug("User '{}' not found in EntraID", login);
262            }
263            else
264            {
265                getLogger().warn("Unable to retrieve user '{}' from EntraID", login, e);
266            }
267        }
268        catch (Exception e)
269        {
270            getLogger().warn("Unable to retrieve user '{}' from EntraID", login, e);
271        }
272
273        return storedUser;
274    }
275
276    /*
277     * As we do not know how to search for email in a "case insensitive" way, we also fill the cache "case sensitively"
278     */
279    public StoredUser getStoredUserByEmail(String email) throws NotUniqueUserException
280    {
281        if (StringUtils.isBlank(email))
282        {
283            return null;
284        }
285        
286        if (getCacheByMail().hasKey(email))
287        {
288            StoredUser storedUser = getCacheByMail().get(email);
289            return storedUser;
290        }
291        
292        List<User> users = _graphClient.users().get(requestConfiguration -> {
293            requestConfiguration.headers.add("ConsistencyLevel", "eventual");
294            requestConfiguration.queryParameters.filter = "mail eq '" + email + "'";
295            requestConfiguration.queryParameters.select = __USER_ATTRIBUTES_SELECT;
296        }).getValue();
297        
298        if (users.size() == 1)
299        {
300            User u = users.get(0);
301            String userIdentifier = getUserIdentifier(u);
302            StoredUser storedUser = new StoredUser(userIdentifier, u.getSurname(), u.getGivenName(), u.getMail());
303            
304            getCacheByMail().put(storedUser.getEmail(), storedUser);
305            
306            return storedUser;
307        }
308        else if (users.isEmpty())
309        {
310            return null;
311        }
312        else
313        {
314            throw new NotUniqueUserException("Find " + users.size() + " users matching the email " + email);
315        }
316    }
317
318    public CredentialsResult checkCredentials(String login, String password)
319    {
320        throw new UnsupportedOperationException("The EntraIDUserDirectory cannot authenticate users");
321    }
322}