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