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.BundleMessage;
23  import davmail.Settings;
24  import davmail.exception.DavMailAuthenticationException;
25  import davmail.http.HttpClientAdapter;
26  import davmail.http.request.RestRequest;
27  import davmail.util.IOUtil;
28  import davmail.util.StringEncryptor;
29  import org.apache.http.Consts;
30  import org.apache.http.NameValuePair;
31  import org.apache.http.client.entity.UrlEncodedFormEntity;
32  import org.apache.http.client.methods.CloseableHttpResponse;
33  import org.apache.http.message.BasicNameValuePair;
34  import org.apache.log4j.Logger;
35  import org.codehaus.jettison.json.JSONException;
36  import org.codehaus.jettison.json.JSONObject;
37  
38  import java.io.IOException;
39  import java.net.UnknownHostException;
40  import java.util.ArrayList;
41  import java.util.Date;
42  
43  /**
44   * O365 token wrapper
45   */
46  public class O365Token {
47  
48      protected static final Logger LOGGER = Logger.getLogger(O365Token.class);
49  
50      private String clientId;
51      private final String tokenUrl;
52      private final String password;
53      private String redirectUri;
54      private String username;
55      private String refreshToken;
56      private String accessToken;
57      private long expiresOn;
58  
59      public O365Token(String tenantId, String clientId, String redirectUri, String password) {
60          this.clientId = clientId;
61          this.redirectUri = redirectUri;
62          this.tokenUrl = buildTokenUrl(tenantId);
63          this.password = password;
64      }
65  
66      public O365Token(String tenantId, String clientId, String redirectUri, String code, String password) throws IOException {
67          this.clientId = clientId;
68          this.redirectUri = redirectUri;
69          this.tokenUrl = buildTokenUrl(tenantId);
70          this.password = password;
71  
72          ArrayList<NameValuePair> parameters = new ArrayList<>();
73          parameters.add(new BasicNameValuePair("grant_type", "authorization_code"));
74          parameters.add(new BasicNameValuePair("code", code));
75          parameters.add(new BasicNameValuePair("redirect_uri", redirectUri));
76          parameters.add(new BasicNameValuePair("client_id", clientId));
77  
78          RestRequest tokenRequest = new RestRequest(tokenUrl, new UrlEncodedFormEntity(parameters, Consts.UTF_8));
79  
80          executeRequest(tokenRequest);
81      }
82  
83      protected O365Token(String tenantId, String clientId, O365DeviceCodeAuthenticator.DeviceCode code, String password) throws IOException {
84          this.clientId = clientId;
85          this.tokenUrl = buildTokenUrl(tenantId);
86          this.password = password;
87  
88          ArrayList<NameValuePair> parameters = new ArrayList<>();
89          parameters.add(new BasicNameValuePair("grant_type", "urn:ietf:params:oauth:grant-type:device_code"));
90          parameters.add(new BasicNameValuePair("code", code.getDeviceCode()));
91          parameters.add(new BasicNameValuePair("redirect_uri", redirectUri));
92          parameters.add(new BasicNameValuePair("client_id", clientId));
93          RestRequest tokenRequest = new RestRequest(tokenUrl, new UrlEncodedFormEntity(parameters, Consts.UTF_8));
94  
95          executeRequest(tokenRequest);
96      }
97  
98      protected String buildTokenUrl(String tenantId) {
99          if (Settings.getBooleanProperty("davmail.enableOidc", false)) {
100             // OIDC configuration visible at https://login.microsoftonline.com/common/v2.0/.well-known/openid-configuration
101             return Settings.getO365LoginUrl()+"/"+tenantId+"/oauth2/v2.0/token";
102         } else {
103             return Settings.getO365LoginUrl()+"/"+tenantId+"/oauth2/token";
104         }
105     }
106 
107 
108     public String getUsername() {
109         return username;
110     }
111 
112     public void setJsonToken(JSONObject jsonToken) throws IOException {
113         String scope;
114         try {
115             final Object error = jsonToken.opt("error");
116             if (error != null) {
117                 if (error.equals("authorization_pending"))
118                     throw new O365AuthorizationPending();
119 
120                 throw new DavMailAuthenticationException("LOG_MESSAGE",jsonToken.optString("error") + " " + jsonToken.optString("error_description"));
121             }
122             scope = jsonToken.optString("scope");
123             LOGGER.debug("Obtained token for scopes: " + scope);
124             // access token expires after one hour
125             accessToken = jsonToken.getString("access_token");
126             // precious refresh token
127             refreshToken = jsonToken.getString("refresh_token");
128             // expires_on is in second, not millisecond
129             expiresOn = jsonToken.optLong("expires_on") * 1000;
130 
131             if (expiresOn > 0) {
132                 LOGGER.debug("Access token expires " + new Date(expiresOn));
133             } else {
134                 long expiresIn = jsonToken.optLong("expires_in") * 1000;
135                 if (expiresIn > 0) {
136                     expiresOn = System.currentTimeMillis()+expiresIn;
137                 }
138             }
139 
140             // get username from id_token
141             String idToken = jsonToken.optString("id_token");
142             if (idToken != null && idToken.contains(".")) {
143                 String decodedJwt = IOUtil.decodeBase64AsString(idToken.substring(idToken.indexOf("."), idToken.lastIndexOf(".")));
144                 try {
145                     JSONObject tokenBody = new JSONObject(decodedJwt);
146                     LOGGER.debug("Token: " + tokenBody);
147                     if ("https://login.live.com".equals(tokenBody.optString("iss"))) {
148                         // live.com token
149                         username = tokenBody.optString("email", null);
150                     } else {
151                         username = tokenBody.optString("unique_name", null);
152                         if (username == null) {
153                             username = tokenBody.optString("preferred_username");
154                         }
155                         // detect live.com token
156                         final String liveDotCom = "live.com#";
157                         if (username != null && username.startsWith(liveDotCom)) {
158                             username = username.substring(liveDotCom.length());
159                         }
160                     }
161                 } catch (JSONException e) {
162                     LOGGER.warn("Invalid id_token " + e.getMessage(), e);
163                 }
164             }
165             // failover: get username from bearer
166             if (username == null) {
167                 String decodedBearer = IOUtil.decodeBase64AsString(accessToken.substring(accessToken.indexOf('.') + 1, accessToken.lastIndexOf('.')) + "==");
168                 JSONObject tokenBody = new JSONObject(decodedBearer);
169                 LOGGER.debug("Token: " + tokenBody);
170                 username = tokenBody.getString("unique_name");
171             }
172 
173             // check token compatibility
174             if (Settings.getBooleanProperty("davmail.enableGraph", false)) {
175                 // Graph token required
176                 if (scope != null && scope.contains("EWS")) {
177                     Settings.storeRefreshToken(username, "");
178                     throw new IOException("Found EWS stored token, incompatible with Graph API");
179                 }
180             } else {
181                 // EWS token required
182                 if (scope != null && !scope.contains("EWS")) {
183                     // clear token
184                     Settings.storeRefreshToken(username, "");
185                     throw new IOException("Found Graph stored token, incompatible with EWS");
186                 }
187             }
188 
189 
190         } catch (JSONException e) {
191             throw new IOException("Exception parsing token", e);
192         }
193     }
194 
195     public void setClientId(String clientId) {
196         this.clientId = clientId;
197     }
198 
199     public void setRedirectUri(String redirectUri) {
200         this.redirectUri = redirectUri;
201     }
202 
203     public String getAccessToken() throws IOException {
204         // detect expiration and refresh token
205         if (isTokenExpired()) {
206             LOGGER.debug("Access token expires soon, trying to refresh it");
207             refreshToken();
208         }
209         //LOGGER.debug("Access token for " + username + " expires in " + (expiresOn - System.currentTimeMillis()) / 60000 + " minutes");
210         return accessToken;
211     }
212 
213     private boolean isTokenExpired() {
214         return System.currentTimeMillis() > (expiresOn - 60000);
215     }
216 
217     public void setAccessToken(String accessToken) {
218         this.accessToken = accessToken;
219         // assume this token is not expired
220         expiresOn = System.currentTimeMillis() + 1000 * 60 * 60;
221     }
222 
223     public void setRefreshToken(String refreshToken) {
224         this.refreshToken = refreshToken;
225     }
226 
227     public String getRefreshToken() {
228         return refreshToken;
229     }
230 
231     public void refreshToken() throws IOException {
232         ArrayList<NameValuePair> parameters = new ArrayList<>();
233         parameters.add(new BasicNameValuePair("grant_type", "refresh_token"));
234         parameters.add(new BasicNameValuePair("refresh_token", refreshToken));
235         parameters.add(new BasicNameValuePair("redirect_uri", redirectUri));
236         parameters.add(new BasicNameValuePair("client_id", clientId));
237 
238         // resource is not relevant over OIDC
239         if (!Settings.getBooleanProperty("davmail.enableGraph", false) && !Settings.getBooleanProperty("davmail.enableOidc", false)) {
240             parameters.add(new BasicNameValuePair("resource", Settings.getOutlookUrl()));
241         }
242 
243         RestRequest tokenRequest = new RestRequest(tokenUrl, new UrlEncodedFormEntity(parameters, Consts.UTF_8));
244 
245         executeRequest(tokenRequest);
246 
247         // persist provided new refresh token
248         persistToken();
249     }
250 
251     private void executeRequest(RestRequest tokenMethod) throws IOException {
252         // do not keep login connections open (no pooling)
253         try (
254                 HttpClientAdapter httpClientAdapter = new HttpClientAdapter(tokenUrl);
255                 CloseableHttpResponse response = httpClientAdapter.execute(tokenMethod)
256         ) {
257             setJsonToken(tokenMethod.handleResponse(response));
258         }
259     }
260 
261     static O365Token build(String tenantId, String clientId, String redirectUri, String code, String password) throws IOException {
262         O365Token token = new O365Token(tenantId, clientId, redirectUri, code, password);
263         token.persistToken();
264         return token;
265     }
266 
267     static O365Token build(String tenantId, String clientId, O365DeviceCodeAuthenticator.DeviceCode code, String password) throws IOException {
268         O365Token token = new O365Token(tenantId, clientId, code, password);
269         token.persistToken();
270         return token;
271     }
272 
273     static O365Token load(String tenantId, String clientId, String redirectUri, String username, String password) throws UnknownHostException {
274         O365Token token = null;
275         if (Settings.getBooleanProperty("davmail.oauth.persistToken", true)) {
276             String encryptedRefreshToken = Settings.loadRefreshToken(username);
277             if (encryptedRefreshToken != null) {
278                 String refreshToken;
279                 try {
280                     refreshToken = decryptToken(encryptedRefreshToken, password);
281                     LOGGER.debug("Loaded stored token for " + username);
282                     O365Token localToken = new O365Token(tenantId, clientId, redirectUri, password);
283 
284                     localToken.setRefreshToken(refreshToken);
285                     localToken.refreshToken();
286                     LOGGER.debug("Authenticated user " + localToken.getUsername() + " from stored token");
287                     token = localToken;
288 
289                 } catch (UnknownHostException e) {
290                     // network down, rethrow to avoid invalidating this token
291                     throw e;
292                 } catch (IOException e) {
293                     LOGGER.error("refresh token failed " + e.getMessage());
294                     // TODO detect network down and rethrow exception
295                 }
296             }
297         }
298         return token;
299     }
300 
301     private void persistToken() throws IOException {
302         if (Settings.getBooleanProperty("davmail.oauth.persistToken", true)) {
303             if (password == null || password.isEmpty()) {
304                 // no password provided, store token unencrypted
305                 Settings.storeRefreshToken(username, refreshToken);
306             } else {
307                 Settings.storeRefreshToken(username, O365Token.encryptToken(refreshToken, password));
308             }
309         }
310     }
311 
312     private static String decryptToken(String encryptedRefreshToken, String password) throws IOException {
313         return new StringEncryptor(password).decryptString(encryptedRefreshToken);
314     }
315 
316     private static String encryptToken(String refreshToken, String password) throws IOException {
317         return new StringEncryptor(password).encryptString(refreshToken);
318     }
319 }