001/*
002 *  Copyright 2020 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.exchange;
017
018import java.net.URL;
019import java.time.LocalDateTime;
020import java.time.ZoneId;
021import java.time.ZoneOffset;
022import java.time.ZonedDateTime;
023import java.time.format.DateTimeFormatter;
024import java.time.temporal.ChronoUnit;
025import java.util.ArrayList;
026import java.util.Collections;
027import java.util.Date;
028import java.util.List;
029import java.util.Map;
030import java.util.Optional;
031import java.util.TreeMap;
032import java.util.concurrent.CompletableFuture;
033import java.util.stream.Collectors;
034
035import javax.annotation.Nonnull;
036
037import org.apache.avalon.framework.service.ServiceException;
038import org.apache.avalon.framework.service.ServiceManager;
039import org.apache.commons.lang3.StringUtils;
040
041import org.ametys.core.user.User;
042import org.ametys.core.user.UserIdentity;
043import org.ametys.core.user.UserManager;
044import org.ametys.core.util.DateUtils;
045import org.ametys.core.util.SessionAttributeProvider;
046import org.ametys.plugins.extrausermgt.authentication.oidc.AbstractOIDCCredentialProvider;
047import org.ametys.plugins.messagingconnector.AbstractMessagingConnector;
048import org.ametys.plugins.messagingconnector.CalendarEvent;
049import org.ametys.plugins.messagingconnector.EmailMessage;
050import org.ametys.plugins.messagingconnector.MessagingConnectorException;
051import org.ametys.plugins.messagingconnector.MessagingConnectorException.ExceptionType;
052import org.ametys.runtime.config.Config;
053
054import com.azure.identity.ClientSecretCredential;
055import com.azure.identity.ClientSecretCredentialBuilder;
056import com.microsoft.graph.authentication.BaseAuthenticationProvider;
057import com.microsoft.graph.authentication.TokenCredentialAuthProvider;
058import com.microsoft.graph.models.Event;
059import com.microsoft.graph.models.Message;
060import com.microsoft.graph.options.QueryOption;
061import com.microsoft.graph.requests.GraphServiceClient;
062import com.microsoft.graph.requests.MessageCollectionPage;
063import com.microsoft.graph.requests.UserRequestBuilder;
064
065/**
066 * The connector used by the messaging connector plugin when connecting to Exchange Online.<br>
067 * Implemented through the Microsoft Graph API.
068 */
069public class GraphConnector extends AbstractMessagingConnector
070{
071    /** The avalon role */
072    public static final String INNER_ROLE = GraphConnector.class.getName();
073    
074    private static final String __SCOPE = "https://graph.microsoft.com/.default";
075    
076    private GraphServiceClient _graphClient;
077    private UserManager _userManager;
078    private SessionAttributeProvider _sessionAttributeProvider;
079
080    @Override
081    public void service(ServiceManager manager) throws ServiceException
082    {
083        super.service(manager);
084        _userManager = (UserManager) manager.lookup(UserManager.ROLE);
085        _sessionAttributeProvider = (SessionAttributeProvider) manager.lookup(SessionAttributeProvider.ROLE);
086    }
087    
088    @Override
089    public void initialize()
090    {
091        super.initialize();
092
093        if ((boolean) Config.getInstance().getValue("org.ametys.plugins.exchange.useadmin"))
094        {
095            String appId = Config.getInstance().getValue("org.ametys.plugins.exchange.appid");
096            String clientSecret = Config.getInstance().getValue("org.ametys.plugins.exchange.clientsecret");
097            String tenant = Config.getInstance().getValue("org.ametys.plugins.exchange.tenant");
098            
099            ClientSecretCredential clientSecretCredential = new ClientSecretCredentialBuilder()
100                    .clientId(appId)
101                    .clientSecret(clientSecret)
102                    .tenantId(tenant)
103                    .build();
104            
105            TokenCredentialAuthProvider tokenCredAuthProvider = new TokenCredentialAuthProvider(List.of(__SCOPE), clientSecretCredential);
106            
107            _graphClient = GraphServiceClient.builder()
108                    .authenticationProvider(tokenCredAuthProvider)
109                    .buildClient();
110        }
111    }
112    
113    private String _getUserPrincipalName(UserIdentity userIdentity)
114    {
115        String authMethod = Config.getInstance().getValue("org.ametys.plugins.exchange.authmethodgraph");
116        if ("email".equals(authMethod))
117        {
118            User user = _userManager.getUser(userIdentity);
119            String email = user.getEmail();
120            if (StringUtils.isBlank(email))
121            {
122                if (getLogger().isWarnEnabled())
123                {
124                    getLogger().warn("The user '" + userIdentity.getLogin() + "' has no email address set, thus exchange cannot be contacted using 'email' authentication method");
125                }
126                
127                return null;
128            }
129            
130            return email;
131        }
132        else
133        {
134            return userIdentity.getLogin();
135        }
136    }
137    
138    private List<Event> _getEvents(UserIdentity userIdentity, int maxDays)
139    {        
140        String userPrincipal = _getUserPrincipalName(userIdentity);
141        if (userPrincipal == null)
142        {
143            return Collections.emptyList();
144        }
145        
146        ZonedDateTime now = ZonedDateTime.now(ZoneOffset.UTC).truncatedTo(ChronoUnit.SECONDS);
147        
148        String start = now.format(DateTimeFormatter.ISO_DATE_TIME);
149        String end = now.plusDays(maxDays).format(DateTimeFormatter.ISO_DATE_TIME);
150        
151        return getUserRequestBuilder(userPrincipal)
152                           .calendar()
153                           .calendarView()
154                           .buildRequest(new QueryOption("startDateTime", start), 
155                                         new QueryOption("endDateTime", end),
156                                         new QueryOption("$orderby", "start/datetime"))
157                           .select("subject,start,end,location")
158                           .get().getCurrentPage();
159    }
160    
161    @Override
162    protected List<CalendarEvent> internalGetEvents(UserIdentity userIdentity, int maxDays, int maxEvents) throws MessagingConnectorException
163    {
164        List<Event> events = _getEvents(userIdentity, maxDays);
165        
166        Map<Long, CalendarEvent> calendarEvents = new TreeMap<>();
167        for (Event event : events)
168        {
169            LocalDateTime ldt = LocalDateTime.parse(event.start.dateTime, DateTimeFormatter.ISO_DATE_TIME);
170            ZonedDateTime zdt = ldt.atZone(ZoneId.of(event.start.timeZone));
171            zdt = zdt.withZoneSameInstant(ZoneId.systemDefault());
172            
173            long longStart = zdt.toEpochSecond();
174            Date startDate = DateUtils.asDate(zdt);
175            
176            ldt = LocalDateTime.parse(event.end.dateTime, DateTimeFormatter.ISO_DATE_TIME);
177            zdt = ldt.atZone(ZoneId.of(event.end.timeZone));
178            zdt = zdt.withZoneSameInstant(ZoneId.systemDefault());
179            
180            Date endDate = DateUtils.asDate(zdt);
181            
182            CalendarEvent newEvent = new CalendarEvent();
183            newEvent.setStartDate(startDate);
184            newEvent.setEndDate(endDate);
185            newEvent.setSubject(event.subject);
186            newEvent.setLocation(event.location.displayName);
187            calendarEvents.put(longStart, newEvent);
188        }
189        
190        return calendarEvents.entrySet().stream().limit(maxEvents).map(e -> e.getValue()).collect(Collectors.toList());
191    }
192
193    @Override
194    protected int internalGetEventsCount(UserIdentity userIdentity, int maxDays) throws MessagingConnectorException
195    {
196        return _getEvents(userIdentity, maxDays).size();
197    }
198
199    @Override
200    protected List<EmailMessage> internalGetEmails(UserIdentity userIdentity, int maxEmails) throws MessagingConnectorException
201    {
202        String userPrincipal = _getUserPrincipalName(userIdentity);
203        if (userPrincipal == null)
204        {
205            return Collections.emptyList();
206        }
207        
208        List<EmailMessage> result = new ArrayList<>();
209        
210        MessageCollectionPage messageCollectionPage = getUserRequestBuilder(userPrincipal)
211                                                                   .mailFolders("inbox").messages().buildRequest()
212                                                                   .top(maxEmails)
213                                                                   .select("sender,subject,bodyPreview")
214                                                                   .filter("isRead eq false")
215                                                                   .get();
216        
217        for (Message message : messageCollectionPage.getCurrentPage())
218        {
219            result.add(new EmailMessage(message.subject, message.sender.emailAddress.address, message.bodyPreview));
220        }
221        
222        return result;
223    }
224
225    @Override
226    protected int internalGetEmailsCount(UserIdentity userIdentity) throws MessagingConnectorException
227    {
228        String userPrincipal = _getUserPrincipalName(userIdentity);
229        if (userPrincipal == null)
230        {
231            return 0;
232        }
233        
234        return getUserRequestBuilder(userPrincipal).mailFolders("inbox").buildRequest().get().unreadItemCount;
235    }
236    
237    private UserRequestBuilder getUserRequestBuilder(String userPrincipal)
238    {
239        if (_graphClient != null)
240        {
241            return _graphClient.users(userPrincipal);
242        }
243        else
244        {
245            Optional<Object> accessToken = _sessionAttributeProvider.getSessionAttribute(AbstractOIDCCredentialProvider.TOKEN_SESSION_ATTRIBUTE);
246            
247            if (accessToken.isEmpty())
248            {  
249                throw new MessagingConnectorException("Missing token for user " + userPrincipal + ", be sure to use Azure Active Directory Credential Provider, "
250                                                    + "and if you authenticate through front-office, check that you have the extra-user-management-site plugin.", ExceptionType.UNAUTHORIZED);
251            }
252            
253            AccessTokenAuthenticationProvider accessTokenAuthenticationProvider = new AccessTokenAuthenticationProvider((String) accessToken.get());
254            final GraphServiceClient graphClient = GraphServiceClient
255                    .builder()
256                    .authenticationProvider(accessTokenAuthenticationProvider)
257                    .buildClient();
258
259            return graphClient.me();
260        }
261    }
262    
263    private static class AccessTokenAuthenticationProvider extends BaseAuthenticationProvider 
264    {
265        private String _accessToken;
266
267        public AccessTokenAuthenticationProvider(@Nonnull String accessToken)
268        {
269            this._accessToken = accessToken;
270        }
271
272        @Override
273        public CompletableFuture<String> getAuthorizationTokenAsync(@Nonnull final URL requestUrl)
274        {
275            return CompletableFuture.completedFuture(_accessToken);
276        }
277    }
278}