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