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}