View Javadoc
1   /*
2    * DavMail POP/IMAP/SMTP/CalDav/LDAP Exchange Gateway
3    * Copyright (C) 2010  Mickael Guessant
4    *
5    * This program is free software; you can redistribute it and/or
6    * modify it under the terms of the GNU General Public License
7    * as published by the Free Software Foundation; either version 2
8    * of the License, or (at your option) any later version.
9    *
10   * This program is distributed in the hope that it will be useful,
11   * but WITHOUT ANY WARRANTY; without even the implied warranty of
12   * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13   * GNU General Public License for more details.
14   *
15   * You should have received a copy of the GNU General Public License
16   * along with this program; if not, write to the Free Software
17   * Foundation, Inc., 51 Franklin Street, Fifth Floor, Boston, MA  02110-1301, USA.
18   */
19  
20  package davmail.exchange.auth;
21  
22  import davmail.Settings;
23  import davmail.exception.DavMailAuthenticationException;
24  import davmail.exception.DavMailException;
25  import davmail.exchange.NetworkDownException;
26  import davmail.http.HttpClientAdapter;
27  import davmail.http.request.RestRequest;
28  import davmail.util.IOUtil;
29  import davmail.util.StringEncryptor;
30  import org.apache.http.Consts;
31  import org.apache.http.NameValuePair;
32  import org.apache.http.client.entity.UrlEncodedFormEntity;
33  import org.apache.http.client.methods.CloseableHttpResponse;
34  import org.apache.http.message.BasicNameValuePair;
35  import org.apache.log4j.Logger;
36  import org.codehaus.jettison.json.JSONException;
37  import org.codehaus.jettison.json.JSONObject;
38  
39  import java.io.IOException;
40  import java.net.UnknownHostException;
41  import java.util.ArrayList;
42  import java.util.Date;
43  
44  /**
45   * O365 token wrapper
46   */
47  public class O365Token {
48  
49      protected static final Logger LOGGER = Logger.getLogger(O365Token.class);
50  
51      private String clientId;
52      private final String tokenUrl;
53      private final String password;
54      private String redirectUri;
55      private String username;
56      private String refreshToken;
57      private String accessToken;
58      private long expiresOn;
59  
60      public O365Token(String tenantId, String clientId, String redirectUri, String password) {
61          this.clientId = clientId;
62          this.redirectUri = redirectUri;
63          this.tokenUrl = buildTokenUrl(tenantId);
64          this.password = password;
65      }
66  
67      public O365Token(String tenantId, String clientId, String redirectUri, String code, String password) throws IOException {
68          this.clientId = clientId;
69          this.redirectUri = redirectUri;
70          this.tokenUrl = buildTokenUrl(tenantId);
71          this.password = password;
72  
73          ArrayList<NameValuePair> parameters = new ArrayList<>();
74          parameters.add(new BasicNameValuePair("grant_type", "authorization_code"));
75          parameters.add(new BasicNameValuePair("code", code));
76          parameters.add(new BasicNameValuePair("redirect_uri", redirectUri));
77          parameters.add(new BasicNameValuePair("client_id", clientId));
78  
79          RestRequest tokenRequest = new RestRequest(tokenUrl, new UrlEncodedFormEntity(parameters, Consts.UTF_8));
80  
81          String origin = Settings.getProperty("davmail.oauth.refreshTokenOrigin");
82          if (origin != null && !origin.isEmpty()) {
83              tokenRequest.setRequestHeader("Origin", origin);
84          }
85  
86          LOGGER.debug("Obtain token for clientId: " + clientId + " redirectUri: " + redirectUri + " tokenUrl: " + tokenUrl);
87          executeRequest(tokenRequest);
88      }
89  
90      protected O365Token(String tenantId, String clientId, O365DeviceCodeAuthenticator.DeviceCode code, String password) throws IOException {
91          this.clientId = clientId;
92          this.tokenUrl = buildTokenUrl(tenantId);
93          this.password = password;
94  
95          ArrayList<NameValuePair> parameters = new ArrayList<>();
96          parameters.add(new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:device_code"));
97          parameters.add(new BasicNameValuePair("code", code.getDeviceCode()));
98          parameters.add(new BasicNameValuePair("redirect_uri", redirectUri));
99          parameters.add(new BasicNameValuePair("client_id", clientId));
100         RestRequest tokenRequest = new RestRequest(tokenUrl, new UrlEncodedFormEntity(parameters, Consts.UTF_8));
101 
102         executeRequest(tokenRequest);
103     }
104 
105     protected String buildTokenUrl(String tenantId) {
106         if (Settings.getBooleanProperty("davmail.enableOidc", Settings.isGraphEnabled())) {
107             // OIDC configuration visible at https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration
108             return Settings.getO365LoginUrl()+"/"+tenantId+"/oauth2/v2.0/token";
109         } else {
110             return Settings.getO365LoginUrl()+"/"+tenantId+"/oauth2/token";
111         }
112     }
113 
114 
115     public String getUsername() {
116         return username;
117     }
118 
119     public void setJsonToken(JSONObject jsonToken) throws IOException {
120         String scope;
121         try {
122             final Object error = jsonToken.opt("error");
123             if (error != null) {
124                 if (error.equals("authorization_pending"))
125                     throw new O365AuthorizationPending();
126 
127                 if (error.equals("invalid_grant") && Settings.getProperty("davmail.oauth.scope") == null) {
128                     LOGGER.warn("received invalid grant and scope is not set, if this is a live.com account please set davmail.oauth.scope=openid profile offline_access Mail.ReadWrite");
129                     Settings.setProperty("davmail.oauth.scope", "openid profile offline_access Mail.ReadWrite");
130                 }
131 
132                 if (error.equals("invalid_grant") && "openid profile offline_access Mail.ReadWrite".equals(Settings.getProperty("davmail.oauth.scope"))
133                     && !Settings.isGraphEnabled()) {
134                     LOGGER.warn("received invalid grant and graph is disabled, if this is a live.com account please set davmail.enableGraph=true");
135                 }
136 
137                 throw new DavMailAuthenticationException("LOG_MESSAGE", jsonToken.optString("error") + " " + jsonToken.optString("error_description"));
138             }
139             scope = jsonToken.optString("scope", null);
140             LOGGER.debug("Obtained token for scopes: " + scope);
141 
142             if (Settings.isGraphEnabled() && scope != null && !scope.contains("Mail.ReadWrite")) {
143                 throw new DavMailAuthenticationException("LOG_MESSAGE", "Received a token without Mail.ReadWrite for a graph session");
144             }
145             // access token expires after one hour
146             accessToken = jsonToken.getString("access_token");
147             // precious refresh token
148             refreshToken = jsonToken.getString("refresh_token");
149             // expires_on is in second, not millisecond
150             expiresOn = jsonToken.optLong("expires_on") * 1000;
151 
152             if (expiresOn > 0) {
153                 LOGGER.debug("Access token expires " + new Date(expiresOn));
154             } else {
155                 long expiresIn = jsonToken.optLong("expires_in") * 1000;
156                 if (expiresIn > 0) {
157                     expiresOn = System.currentTimeMillis()+expiresIn;
158                 }
159             }
160 
161             // get username from id_token
162             String idToken = jsonToken.optString("id_token");
163             if (idToken != null && idToken.contains(".")) {
164                 String decodedJwt = IOUtil.decodeBase64AsString(idToken.substring(idToken.indexOf("."), idToken.lastIndexOf(".")));
165                 try {
166                     JSONObject tokenBody = new JSONObject(decodedJwt);
167                     LOGGER.debug("Token: " + tokenBody);
168                     if ("https://login.live.com".equals(tokenBody.optString("iss"))) {
169                         // live.com token
170                         username = tokenBody.optString("email", null);
171                     } else {
172                         username = tokenBody.optString("unique_name", null);
173                         if (username == null) {
174                             username = tokenBody.optString("preferred_username");
175                         }
176                         // detect live.com token
177                         final String liveDotCom = "live.com#";
178                         if (username != null && username.startsWith(liveDotCom)) {
179                             username = username.substring(liveDotCom.length());
180                         }
181                     }
182                 } catch (JSONException e) {
183                     LOGGER.warn("Invalid id_token " + e.getMessage(), e);
184                 }
185             }
186             // failover: get username from bearer
187             if (username == null && accessToken.contains(".")) {
188                 String decodedBearer = IOUtil.decodeBase64AsString(accessToken.substring(accessToken.indexOf('.') + 1, accessToken.lastIndexOf('.')) + "==");
189                 JSONObject tokenBody = new JSONObject(decodedBearer);
190                 LOGGER.debug("Token: " + tokenBody);
191                 username = tokenBody.getString("unique_name");
192             }
193 
194             // check token compatibility
195             if (Settings.isGraphEnabled()) {
196                 // Graph token required
197                 if (scope != null && (!scope.contains("Mail.ReadWrite") || scope.contains(Settings.getOutlookUrl()))) {
198                     Settings.storeRefreshToken(username, "");
199                     throw new IOException("Found EWS stored token, incompatible with Graph API");
200                 }
201             } else {
202                 // EWS token required
203                 if (scope != null && !scope.contains("EWS")) {
204                     // clear token
205                     Settings.storeRefreshToken(username, "");
206                     throw new IOException("Found Graph stored token, incompatible with EWS");
207                 }
208             }
209 
210 
211         } catch (JSONException e) {
212             throw new DavMailException("LOG_MESSAGE", "Exception parsing token "+e.getMessage(), e);
213         }
214     }
215 
216     public void setClientId(String clientId) {
217         this.clientId = clientId;
218     }
219 
220     public void setRedirectUri(String redirectUri) {
221         this.redirectUri = redirectUri;
222     }
223 
224     public String getAccessToken() throws IOException {
225         // detect expiration and refresh token
226         if (isTokenExpired()) {
227             LOGGER.debug("Access token expires soon, trying to refresh it");
228             refreshToken();
229         }
230         //LOGGER.debug("Access token for " + username + " expires in " + (expiresOn - System.currentTimeMillis()) / 60000 + " minutes");
231         return accessToken;
232     }
233 
234     private boolean isTokenExpired() {
235         return System.currentTimeMillis() > (expiresOn - 60000);
236     }
237 
238     public void setAccessToken(String accessToken) {
239         this.accessToken = accessToken;
240         // assume this token is not expired
241         expiresOn = System.currentTimeMillis() + 1000 * 60 * 60;
242     }
243 
244     public void setRefreshToken(String refreshToken) {
245         this.refreshToken = refreshToken;
246     }
247 
248     public String getRefreshToken() {
249         return refreshToken;
250     }
251 
252     public void refreshToken() throws IOException {
253         ArrayList<NameValuePair> parameters = new ArrayList<>();
254         parameters.add(new BasicNameValuePair("grant_type", "refresh_token"));
255         parameters.add(new BasicNameValuePair("refresh_token", refreshToken));
256         parameters.add(new BasicNameValuePair("redirect_uri", redirectUri));
257         parameters.add(new BasicNameValuePair("client_id", clientId));
258 
259         // resource is not relevant over OIDC
260         if (!Settings.getBooleanProperty("davmail.enableOidc", Settings.isGraphEnabled())) {
261             parameters.add(new BasicNameValuePair("resource", Settings.isGraphEnabled() ? Settings.getGraphUrl() : Settings.getOutlookUrl()));
262         }
263 
264         RestRequest tokenRequest = new RestRequest(tokenUrl, new UrlEncodedFormEntity(parameters, Consts.UTF_8));
265 
266         String origin = Settings.getProperty("davmail.oauth.refreshTokenOrigin");
267         if (origin != null && !origin.isEmpty()) {
268             tokenRequest.setRequestHeader("Origin", origin);
269         }
270 
271         try {
272             executeRequest(tokenRequest);
273         } catch (UnknownHostException e) {
274             // unknown host on refresh means network is down
275             LOGGER.debug("refresh token failed " + e.getMessage());
276             throw e;
277         }
278 
279         // persist provided new refresh token
280         persistToken();
281     }
282 
283     private void executeRequest(RestRequest tokenMethod) throws IOException {
284         // do not keep login connections open (no pooling)
285         try (
286                 HttpClientAdapter httpClientAdapter = new HttpClientAdapter(tokenUrl);
287                 CloseableHttpResponse response = httpClientAdapter.execute(tokenMethod)
288         ) {
289             setJsonToken(tokenMethod.handleResponse(response));
290         }
291     }
292 
293     static O365Token build(String tenantId, String clientId, String redirectUri, String code, String password) throws IOException {
294         O365Token token = new O365Token(tenantId, clientId, redirectUri, code, password);
295         token.persistToken();
296         return token;
297     }
298 
299     static O365Token build(String tenantId, String clientId, O365DeviceCodeAuthenticator.DeviceCode code, String password) throws IOException {
300         O365Token token = new O365Token(tenantId, clientId, code, password);
301         token.persistToken();
302         return token;
303     }
304 
305     static O365Token load(String tenantId, String clientId, String redirectUri, String username, String password) throws UnknownHostException {
306         O365Token token = null;
307         if (Settings.getBooleanProperty("davmail.oauth.persistToken", true)) {
308             String encryptedRefreshToken = Settings.loadRefreshToken(username);
309             if (encryptedRefreshToken != null) {
310                 String refreshToken;
311                 try {
312                     refreshToken = decryptToken(encryptedRefreshToken, password);
313                     LOGGER.debug("Loaded stored token for " + username);
314                     O365Token localToken = new O365Token(tenantId, clientId, redirectUri, password);
315 
316                     localToken.setRefreshToken(refreshToken);
317                     localToken.refreshToken();
318                     LOGGER.debug("Authenticated user " + localToken.getUsername() + " from stored token");
319                     token = localToken;
320 
321                 } catch (UnknownHostException e) {
322                     // network down, rethrow to avoid invalidating this token
323                     throw e;
324                 } catch (IOException e) {
325                     LOGGER.error("refresh token failed " + e.getMessage());
326                 }
327             }
328         }
329         return token;
330     }
331 
332     private void persistToken() throws IOException {
333         if (Settings.getBooleanProperty("davmail.oauth.persistToken", true)) {
334             if (password == null || password.isEmpty()) {
335                 // no password provided, store token unencrypted
336                 Settings.storeRefreshToken(username, refreshToken);
337             } else {
338                 Settings.storeRefreshToken(username, O365Token.encryptToken(refreshToken, password));
339             }
340         }
341     }
342 
343     private static String decryptToken(String encryptedRefreshToken, String password) throws IOException {
344         return new StringEncryptor(password).decryptString(encryptedRefreshToken);
345     }
346 
347     private static String encryptToken(String refreshToken, String password) throws IOException {
348         return new StringEncryptor(password).encryptString(refreshToken);
349     }
350 }