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 = Settings.getO365LoginUrl() + "/" + tenantId + "/oauth2/token";
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 = Settings.getO365LoginUrl() + "/" + tenantId + "/oauth2/token";
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  
82      public String getUsername() {
83          return username;
84      }
85  
86      public void setJsonToken(JSONObject jsonToken) throws IOException {
87          try {
88              if (jsonToken.opt("error") != null) {
89                  throw new IOException(jsonToken.optString("error") + " " + jsonToken.optString("error_description"));
90              }
91              // access token expires after one hour
92              accessToken = jsonToken.getString("access_token");
93              // precious refresh token
94              refreshToken = jsonToken.getString("refresh_token");
95              // expires_on is in second, not millisecond
96              expiresOn = jsonToken.getLong("expires_on") * 1000;
97  
98              LOGGER.debug("Access token expires " + new Date(expiresOn));
99  
100             // get username from id_token
101             String idToken = jsonToken.optString("id_token");
102             if (idToken != null && idToken.contains(".")) {
103                 String decodedJwt = IOUtil.decodeBase64AsString(idToken.substring(idToken.indexOf("."), idToken.lastIndexOf(".")));
104                 try {
105                     JSONObject tokenBody = new JSONObject(decodedJwt);
106                     LOGGER.debug("Token: " + tokenBody);
107                     username = tokenBody.getString("unique_name");
108                     // detect live.com token
109                     final String liveDotCom = "live.com#";
110                     if (username.startsWith(liveDotCom)) {
111                         username = username.substring(liveDotCom.length());
112                     }
113                 } catch (JSONException e) {
114                     LOGGER.warn("Invalid id_token " + e.getMessage(), e);
115                 }
116             }
117             // failover: get username from bearer
118             if (username == null) {
119                 String decodedBearer = IOUtil.decodeBase64AsString(accessToken.substring(accessToken.indexOf('.') + 1, accessToken.lastIndexOf('.')) + "==");
120                 JSONObject tokenBody = new JSONObject(decodedBearer);
121                 LOGGER.debug("Token: " + tokenBody);
122                 username = tokenBody.getString("unique_name");
123             }
124 
125         } catch (JSONException e) {
126             throw new IOException("Exception parsing token", e);
127         }
128     }
129 
130     public void setClientId(String clientId) {
131         this.clientId = clientId;
132     }
133 
134     public void setRedirectUri(String redirectUri) {
135         this.redirectUri = redirectUri;
136     }
137 
138     public String getAccessToken() throws IOException {
139         // detect expiration and refresh token
140         if (isTokenExpired()) {
141             LOGGER.debug("Access token expires soon, trying to refresh it");
142             refreshToken();
143         }
144         //LOGGER.debug("Access token for " + username + " expires in " + (expiresOn - System.currentTimeMillis()) / 60000 + " minutes");
145         return accessToken;
146     }
147 
148     private boolean isTokenExpired() {
149         return System.currentTimeMillis() > (expiresOn - 60000);
150     }
151 
152     public void setAccessToken(String accessToken) {
153         this.accessToken = accessToken;
154         // assume unexpired token
155         expiresOn = System.currentTimeMillis() + 1000 * 60 * 60;
156     }
157 
158     public void setRefreshToken(String refreshToken) {
159         this.refreshToken = refreshToken;
160     }
161 
162     public String getRefreshToken() {
163         return refreshToken;
164     }
165 
166     public void refreshToken() throws IOException {
167         ArrayList<NameValuePair> parameters = new ArrayList<>();
168         parameters.add(new BasicNameValuePair("grant_type", "refresh_token"));
169         parameters.add(new BasicNameValuePair("refresh_token", refreshToken));
170         parameters.add(new BasicNameValuePair("redirect_uri", redirectUri));
171         parameters.add(new BasicNameValuePair("client_id", clientId));
172         parameters.add(new BasicNameValuePair("resource", Settings.getOutlookUrl()));
173 
174         RestRequest tokenRequest = new RestRequest(tokenUrl, new UrlEncodedFormEntity(parameters, Consts.UTF_8));
175 
176         executeRequest(tokenRequest);
177 
178         // persist new refresh token
179         persistToken();
180     }
181 
182     private void executeRequest(RestRequest tokenMethod) throws IOException {
183         // do not keep login connections open (no pooling)
184         try (
185                 HttpClientAdapter httpClientAdapter = new HttpClientAdapter(tokenUrl);
186                 CloseableHttpResponse response = httpClientAdapter.execute(tokenMethod)
187         ) {
188             setJsonToken(tokenMethod.handleResponse(response));
189         }
190     }
191 
192     static O365Token build(String tenantId, String clientId, String redirectUri, String code, String password) throws IOException {
193         O365Token token = new O365Token(tenantId, clientId, redirectUri, code, password);
194         token.persistToken();
195         return token;
196     }
197 
198 
199     static O365Token load(String tenantId, String clientId, String redirectUri, String username, String password) throws UnknownHostException {
200         O365Token token = null;
201         if (Settings.getBooleanProperty("davmail.oauth.persistToken", true)) {
202             String encryptedRefreshToken = Settings.loadRefreshToken(username);
203             if (encryptedRefreshToken != null) {
204                 String refreshToken;
205                 try {
206                     refreshToken = decryptToken(encryptedRefreshToken, password);
207                     LOGGER.debug("Loaded stored token for " + username);
208                     O365Token localToken = new O365Token(tenantId, clientId, redirectUri, password);
209 
210                     localToken.setRefreshToken(refreshToken);
211                     localToken.refreshToken();
212                     LOGGER.debug("Authenticated user " + localToken.getUsername() + " from stored token");
213                     token = localToken;
214 
215                 } catch (UnknownHostException e) {
216                     // network down, rethrow to avoid invalidating token
217                     throw e;
218                 } catch (IOException e) {
219                     LOGGER.error("refresh token failed " + e.getMessage());
220                     // TODO detect network down and rethrow exception
221                 }
222             }
223         }
224         return token;
225     }
226 
227     private void persistToken() throws IOException {
228         if (Settings.getBooleanProperty("davmail.oauth.persistToken", true)) {
229             if (password == null || password.isEmpty()) {
230                 // no password provided, store token unencrypted
231                 Settings.storeRefreshToken(username, refreshToken);
232             } else {
233                 Settings.storeRefreshToken(username, O365Token.encryptToken(refreshToken, password));
234             }
235         }
236     }
237 
238     private static String decryptToken(String encryptedRefreshToken, String password) throws IOException {
239         return new StringEncryptor(password).decryptString(encryptedRefreshToken);
240     }
241 
242     private static String encryptToken(String refreshToken, String password) throws IOException {
243         return new StringEncryptor(password).encryptString(refreshToken);
244     }
245 }