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