/*******************************************************************************
* Copyright (c) 2012-2017 Codenvy, S.A.
* All rights reserved. This program and the accompanying materials
* are made available under the terms of the Eclipse Public License v1.0
* which accompanies this distribution, and is available at
* http://www.eclipse.org/legal/epl-v10.html
*
* Contributors:
* Codenvy, S.A. - initial API and implementation
*******************************************************************************/
package org.eclipse.che.security.oauth;
import com.google.api.client.auth.oauth2.AuthorizationCodeFlow;
import com.google.api.client.auth.oauth2.AuthorizationCodeRequestUrl;
import com.google.api.client.auth.oauth2.AuthorizationCodeResponseUrl;
import com.google.api.client.auth.oauth2.BearerToken;
import com.google.api.client.auth.oauth2.ClientParametersAuthentication;
import com.google.api.client.auth.oauth2.Credential;
import com.google.api.client.auth.oauth2.TokenResponse;
import com.google.api.client.http.GenericUrl;
import com.google.api.client.http.javanet.NetHttpTransport;
import com.google.api.client.json.jackson2.JacksonFactory;
import com.google.api.client.util.store.MemoryDataStoreFactory;
import org.eclipse.che.api.auth.shared.dto.OAuthToken;
import org.eclipse.che.commons.json.JsonHelper;
import org.eclipse.che.commons.json.JsonParseException;
import org.eclipse.che.security.oauth.shared.OAuthTokenProvider;
import org.eclipse.che.security.oauth.shared.User;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import javax.ws.rs.core.MediaType;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URI;
import java.net.URL;
import java.net.URLDecoder;
import java.util.Arrays;
import java.util.Collections;
import java.util.HashMap;
import java.util.List;
import java.util.Map;
import java.util.regex.Pattern;
import static org.eclipse.che.dto.server.DtoFactory.newDto;
/** Authentication service which allow get access token from OAuth provider site. */
public abstract class OAuthAuthenticator {
private static final Logger LOG = LoggerFactory.getLogger(OAuthAuthenticator.class);
private AuthorizationCodeFlow flow;
protected Map<Pattern, String> redirectUrisMap;
/**
* @see {@link #configure(String, String, String[], String, String, MemoryDataStoreFactory, List)}
*/
protected void configure(String clientId, String clientSecret, String[] redirectUris, String authUri, String tokenUri,
MemoryDataStoreFactory dataStoreFactory) throws IOException {
configure(clientId, clientSecret, redirectUris, authUri, tokenUri, dataStoreFactory, Collections.emptyList());
}
/**
* This method should be invoked by child class for initialization default instance of {@link AuthorizationCodeFlow}
* that will be used for authorization
*/
protected void configure(String clientId, String clientSecret, String[] redirectUris, String authUri, String tokenUri,
MemoryDataStoreFactory dataStoreFactory, List<String> scopes) throws IOException {
final AuthorizationCodeFlow authorizationFlow = new AuthorizationCodeFlow.Builder(BearerToken.authorizationHeaderAccessMethod(),
new NetHttpTransport(),
new JacksonFactory(),
new GenericUrl(tokenUri),
new ClientParametersAuthentication(
clientId,
clientSecret),
clientId,
authUri).setDataStoreFactory(dataStoreFactory)
.setScopes(scopes)
.build();
LOG.debug("clientId={}, clientSecret={}, redirectUris={} , authUri={}, tokenUri={}, dataStoreFactory={}",
clientId,
clientSecret,
redirectUris,
authUri,
tokenUri,
dataStoreFactory);
configure(authorizationFlow, Arrays.asList(redirectUris));
}
/**
* This method should be invoked by child class for setting instance of {@link AuthorizationCodeFlow}
* that will be used for authorization
*/
protected void configure(AuthorizationCodeFlow flow, List<String> redirectUris) {
this.flow = flow;
this.redirectUrisMap = new HashMap<>(redirectUris.size());
for (String uri : redirectUris) {
// Redirect URI may be in form urn:ietf:wg:oauth:2.0:oob os use java.net.URI instead of java.net.URL
this.redirectUrisMap.put(Pattern.compile("([a-z0-9\\-]+\\.)?" + URI.create(uri).getHost()), uri);
}
}
/**
* Create authentication URL.
*
* @param requestUrl
* URL of current HTTP request. This parameter required to be able determine URL for redirection after
* authentication. If URL contains query parameters they will be copy to 'state' parameter and returned to
* callback method.
* @param scopes
* specify exactly what type of access needed
* @return URL for authentication
*/
public String getAuthenticateUrl(URL requestUrl, List<String> scopes) throws OAuthAuthenticationException {
if (!isConfigured()) {
throw new OAuthAuthenticationException("Authenticator is not configured");
}
AuthorizationCodeRequestUrl url = flow.newAuthorizationUrl().setRedirectUri(findRedirectUrl(requestUrl))
.setScopes(scopes);
url.setState(prepareState(requestUrl));
return url.build();
}
protected String prepareState(URL requestUrl) {
StringBuilder state = new StringBuilder();
String query = requestUrl.getQuery();
if (query != null) {
if (state.length() > 0) {
state.append('&');
}
state.append(query);
}
return state.toString();
}
protected String findRedirectUrl(URL requestUrl) {
final String requestHost = requestUrl.getHost();
for (Map.Entry<Pattern, String> e : redirectUrisMap.entrySet()) {
if (e.getKey().matcher(requestHost).matches()) {
return e.getValue();
}
}
return null; // TODO : throw exception instead of return null ???
}
/**
* Process callback request.
*
* @param requestUrl
* request URI. URI should contain authorization code generated by authorization server
* @param scopes
* specify exactly what type of access needed. This list must be exactly the same as list passed to the method
* {@link #getAuthenticateUrl(URL, java.util.List)}
* @return id of authenticated user
* @throws OAuthAuthenticationException
* if authentication failed or <code>requestUrl</code> does not contain required parameters, e.g. 'code'
*/
public String callback(URL requestUrl, List<String> scopes) throws OAuthAuthenticationException {
if (!isConfigured()) {
throw new OAuthAuthenticationException("Authenticator is not configured");
}
AuthorizationCodeResponseUrl authorizationCodeResponseUrl = new AuthorizationCodeResponseUrl(requestUrl.toString());
final String error = authorizationCodeResponseUrl.getError();
if (error != null) {
throw new OAuthAuthenticationException("Authentication failed: " + error);
}
final String code = authorizationCodeResponseUrl.getCode();
if (code == null) {
throw new OAuthAuthenticationException("Missing authorization code. ");
}
try {
TokenResponse tokenResponse = flow.newTokenRequest(code).setRequestInitializer(request -> {
if (request.getParser() == null) {
request.setParser(flow.getJsonFactory().createJsonObjectParser());
}
request.getHeaders().setAccept(MediaType.APPLICATION_JSON);
}).setRedirectUri(findRedirectUrl(requestUrl)).setScopes(scopes).execute();
String userId = getUserFromUrl(authorizationCodeResponseUrl);
if (userId == null) {
userId = getUser(newDto(OAuthToken.class).withToken(tokenResponse.getAccessToken())).getId();
}
flow.createAndStoreCredential(tokenResponse, userId);
return userId;
} catch (IOException ioe) {
throw new OAuthAuthenticationException(ioe.getMessage());
}
}
/**
* Get user info.
*
* @param accessToken
* oauth access token
* @return user info
* @throws OAuthAuthenticationException
* if fail to get user info
*/
public abstract User getUser(OAuthToken accessToken) throws OAuthAuthenticationException;
/**
* Get the name of OAuth provider supported by current implementation.
*
* @return oauth provider name
*/
public abstract String getOAuthProvider();
private String getUserFromUrl(AuthorizationCodeResponseUrl authorizationCodeResponseUrl) throws IOException {
String state = authorizationCodeResponseUrl.getState();
if (!(state == null || state.isEmpty())) {
String decoded = URLDecoder.decode(state, "UTF-8");
String[] items = decoded.split("&");
for (String str : items) {
if (str.startsWith("userId=")) {
return str.substring(7, str.length());
}
}
}
return null;
}
protected <O> O getJson(String getUserUrl, Class<O> userClass) throws OAuthAuthenticationException {
HttpURLConnection urlConnection = null;
InputStream urlInputStream = null;
try {
urlConnection = (HttpURLConnection)new URL(getUserUrl).openConnection();
urlInputStream = urlConnection.getInputStream();
return JsonHelper.fromJson(urlInputStream, userClass, null);
} catch (JsonParseException | IOException e) {
throw new OAuthAuthenticationException(e.getMessage(), e);
} finally {
if (urlInputStream != null) {
try {
urlInputStream.close();
} catch (IOException ignored) {
}
}
if (urlConnection != null) {
urlConnection.disconnect();
}
}
}
/**
* Return authorization token by userId.
* <p/>
* WARN!!!. DO not use it directly.
*
* @param userId
* user identifier
* @return token value or {@code null}. When user have valid token then it will be returned,
* when user have expired token and it can be refreshed then refreshed value will be returned,
* when none token found for user then {@code null} will be returned,
* when user have expired token and it can't be refreshed then {@code null} will be returned
* @throws IOException
* when error occurs during token loading
* @see OAuthTokenProvider#getToken(String, String)
*/
public OAuthToken getToken(String userId) throws IOException {
if (!isConfigured()) {
throw new IOException("Authenticator is not configured");
}
Credential credential = flow.loadCredential(userId);
if (credential == null) {
return null;
}
final Long expirationTime = credential.getExpiresInSeconds();
if (expirationTime != null && expirationTime < 0) {
boolean tokenRefreshed;
try {
tokenRefreshed = credential.refreshToken();
} catch (IOException ioEx) {
tokenRefreshed = false;
}
if (tokenRefreshed) {
credential = flow.loadCredential(userId);
} else {
// if token is not refreshed then old value should be invalidated
// and null result should be returned
try {
invalidateToken(userId);
} catch (IOException ignored) {
}
return null;
}
}
return newDto(OAuthToken.class).withToken(credential.getAccessToken());
}
/**
* Invalidate OAuth token for specified user.
*
* @param userId
* user
* @return <code>true</code> if OAuth token invalidated and <code>false</code> otherwise, e.g. if user does not have
* token yet
*/
public boolean invalidateToken(String userId) throws IOException {
Credential credential = flow.loadCredential(userId);
if (credential != null) {
flow.getCredentialDataStore().delete(userId);
return true;
}
return false;
}
/**
* Checks configuring of authenticator
*
* @return true only if authenticator have valid configuration data and it is able to authorize
* otherwise returns false
*/
public boolean isConfigured() {
return flow != null;
}
}