001/* 002 * Copyright 2024 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.aad; 017 018import java.net.URL; 019import java.util.List; 020import java.util.concurrent.CompletableFuture; 021 022import javax.annotation.Nonnull; 023 024import org.apache.avalon.framework.activity.Initializable; 025import org.apache.avalon.framework.component.Component; 026import org.apache.avalon.framework.service.ServiceException; 027import org.apache.avalon.framework.service.ServiceManager; 028import org.apache.avalon.framework.service.Serviceable; 029import org.apache.commons.lang3.StringUtils; 030 031import org.ametys.core.user.CurrentUserProvider; 032import org.ametys.core.user.User; 033import org.ametys.core.user.UserIdentity; 034import org.ametys.core.user.UserManager; 035import org.ametys.core.util.SessionAttributeProvider; 036import org.ametys.plugins.extrausermgt.authentication.msal.AbstractMSALCredentialProvider; 037import org.ametys.runtime.config.Config; 038import org.ametys.runtime.plugin.component.AbstractLogEnabled; 039 040import com.azure.identity.ClientSecretCredential; 041import com.azure.identity.ClientSecretCredentialBuilder; 042import com.microsoft.graph.authentication.BaseAuthenticationProvider; 043import com.microsoft.graph.authentication.TokenCredentialAuthProvider; 044import com.microsoft.graph.requests.GraphServiceClient; 045import com.microsoft.graph.requests.UserRequestBuilder; 046 047import okhttp3.Request; 048 049/** 050 * Provide a Graph client for a user. 051 * Depending on the configuration, the client can either be authenticated 052 * using an application credentials or the current user Entra id token 053 */ 054public class GraphClientProvider extends AbstractLogEnabled implements Initializable, Serviceable, Component 055{ 056 /** The avalon role */ 057 public static final String ROLE = GraphClientProvider.class.getName(); 058 059 private static final String __SCOPE = "https://graph.microsoft.com/.default"; 060 061 private GraphServiceClient<Request> _graphClient; 062 063 private CurrentUserProvider _currentUserProvider; 064 private SessionAttributeProvider _sessionAttributeProvider; 065 private UserManager _userManager; 066 067 public void service(ServiceManager manager) throws ServiceException 068 { 069 _currentUserProvider = (CurrentUserProvider) manager.lookup(CurrentUserProvider.ROLE); 070 _sessionAttributeProvider = (SessionAttributeProvider) manager.lookup(SessionAttributeProvider.ROLE); 071 _userManager = (UserManager) manager.lookup(UserManager.ROLE); 072 } 073 074 public void initialize() 075 { 076 if ((boolean) Config.getInstance().getValue("org.ametys.plugins.extra-user-management.graph.useadmin")) 077 { 078 String appId = Config.getInstance().getValue("org.ametys.plugins.extra-user-management.graph.appid"); 079 String clientSecret = Config.getInstance().getValue("org.ametys.plugins.extra-user-management.graph.clientsecret"); 080 String tenant = Config.getInstance().getValue("org.ametys.plugins.extra-user-management.graph.tenant"); 081 082 ClientSecretCredential clientSecretCredential = new ClientSecretCredentialBuilder() 083 .clientId(appId) 084 .clientSecret(clientSecret) 085 .tenantId(tenant) 086 .build(); 087 088 TokenCredentialAuthProvider tokenCredAuthProvider = new TokenCredentialAuthProvider(List.of(__SCOPE), clientSecretCredential); 089 090 _graphClient = GraphServiceClient.builder() 091 .authenticationProvider(tokenCredAuthProvider) 092 .buildClient(); 093 } 094 } 095 096 /** 097 * Get a graph client for the user. 098 * No check is performed to ensure that the user is actually a valid graph user 099 * @param userIdentity the user identity 100 * @return the client 101 * @throws GraphClientException when its not possible to retrieve a client for the user 102 */ 103 public UserRequestBuilder getUserRequestBuilder(UserIdentity userIdentity) throws GraphClientException 104 { 105 if (_graphClient != null) 106 { 107 return _graphClient.users(_getUserPrincipalName(userIdentity)); 108 } 109 else 110 { 111 // This mode can only provide a client for the current user 112 // check that the requested user is the current user before getting the client 113 if (!userIdentity.equals(_currentUserProvider.getUser())) 114 { 115 throw new GraphClientException(userIdentity.toString() + " is not the current user. A graph client can only be retrieved for the current user"); 116 } 117 return _sessionAttributeProvider.getSessionAttribute(AbstractMSALCredentialProvider.ACCESS_TOKEN_SESSION_ATTRIBUTE) 118 .filter(String.class::isInstance) 119 .map(String.class::cast) 120 .map(this::_getClientFromToken) 121 .map(GraphServiceClient::me) 122 .orElseThrow(() -> new GraphClientException("The current user " + _currentUserProvider.getUser().toString() + "is not logged with Entra ID.")); 123 } 124 } 125 126 /** 127 * Get an authenticated Graph client 128 * @return a Graph client 129 * @throws GraphClientException when its not possible to retrieve a client for the user 130 */ 131 public GraphServiceClient getGraphClient() throws GraphClientException 132 { 133 if (_graphClient != null) 134 { 135 return _graphClient; 136 } 137 else 138 { 139 return _sessionAttributeProvider.getSessionAttribute(AbstractMSALCredentialProvider.ACCESS_TOKEN_SESSION_ATTRIBUTE) 140 .filter(String.class::isInstance) 141 .map(String.class::cast) 142 .map(this::_getClientFromToken) 143 .orElseThrow(() -> new GraphClientException("The current user " + _currentUserProvider.getUser().toString() + "is not logged with Entra ID.")); 144 } 145 } 146 147 private GraphServiceClient _getClientFromToken(String token) 148 { 149 AccessTokenAuthenticationProvider accessTokenAuthenticationProvider = new AccessTokenAuthenticationProvider(token); 150 final GraphServiceClient graphClient = GraphServiceClient 151 .builder() 152 .authenticationProvider(accessTokenAuthenticationProvider) 153 .buildClient(); 154 155 return graphClient; 156 } 157 158 private String _getUserPrincipalName(UserIdentity userIdentity) throws GraphClientException 159 { 160 if ("email".equals(Config.getInstance().getValue("org.ametys.plugins.extra-user-management.graph.authmethod"))) 161 { 162 User user = _userManager.getUser(userIdentity); 163 String email = user.getEmail(); 164 if (StringUtils.isBlank(email)) 165 { 166 throw new GraphClientException("The user '" + userIdentity.toString() + "' has no email address set, thus exchange cannot be contacted using 'email' authentication method"); 167 } 168 169 return email; 170 } 171 else 172 { 173 return userIdentity.getLogin(); 174 } 175 } 176 177 /** 178 * Exception related to the Graph client 179 */ 180 public static class GraphClientException extends Exception 181 { 182 /** 183 * Constructs a new exception with the specified detail message. 184 * @param message the detail message. 185 */ 186 public GraphClientException(String message) 187 { 188 super(message); 189 } 190 } 191 192 private static class AccessTokenAuthenticationProvider extends BaseAuthenticationProvider 193 { 194 private String _accessToken; 195 196 public AccessTokenAuthenticationProvider(@Nonnull String accessToken) 197 { 198 this._accessToken = accessToken; 199 } 200 201 @Override 202 public CompletableFuture<String> getAuthorizationTokenAsync(@Nonnull final URL requestUrl) 203 { 204 return CompletableFuture.completedFuture(_accessToken); 205 } 206 } 207}