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}