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