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}