| package com.googlesource.gerrit.plugins.github.oauth; |
| |
| import com.google.common.base.Charsets; |
| import com.google.common.base.Strings; |
| import com.google.common.io.BaseEncoding; |
| import com.google.common.io.CharStreams; |
| import com.google.gson.Gson; |
| import com.google.inject.Inject; |
| import com.google.inject.Provider; |
| import com.google.inject.Singleton; |
| |
| import org.apache.http.HttpResponse; |
| import org.apache.http.NameValuePair; |
| import org.apache.http.client.HttpClient; |
| import org.apache.http.client.entity.UrlEncodedFormEntity; |
| import org.apache.http.client.methods.HttpPost; |
| import org.apache.http.message.BasicNameValuePair; |
| import org.apache.http.util.EntityUtils; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import java.io.IOException; |
| import java.io.InputStream; |
| import java.io.InputStreamReader; |
| import java.io.UnsupportedEncodingException; |
| import java.net.HttpURLConnection; |
| import java.net.URLEncoder; |
| import java.nio.charset.StandardCharsets; |
| import java.security.NoSuchAlgorithmException; |
| import java.security.SecureRandom; |
| import java.util.ArrayList; |
| import java.util.List; |
| import java.util.Objects; |
| import java.util.Set; |
| |
| import javax.servlet.ServletRequest; |
| import javax.servlet.http.HttpServletRequest; |
| import javax.servlet.http.HttpServletResponse; |
| |
| import lombok.Getter; |
| |
| import com.google.gerrit.extensions.auth.oauth.OAuthToken; |
| import com.google.gerrit.extensions.auth.oauth.OAuthVerifier; |
| |
| @Singleton |
| public class OAuthProtocol { |
| /** |
| * Supported OAuth Scopes. |
| * |
| * OAuth authorization scopes as defined in GitHub API at: |
| * https://developer.github.com/v3/oauth/#scopes |
| */ |
| public static enum Scope { |
| /** |
| * Grants read-only access to public information (includes public user |
| * profile info, public repository info, and gists) |
| */ |
| DEFAULT("", "Read-only public information"), |
| |
| /** |
| * Grants read/write access to profile info only. Note that this scope |
| * includes user:email and user:follow. |
| */ |
| USER("user", "Read/write profile info"), |
| |
| /** |
| * Grants read access to a user’s email addresses. |
| */ |
| USER_EMAIL("user:email", "Read-only user's e-mail"), |
| |
| /** |
| * Grants access to follow or unfollow other users. |
| */ |
| USER_FOLLOW("user:follow", "(Un)Follow users"), |
| |
| /** |
| * Grants read/write access to code, commit statuses, collaborators, and |
| * deployment statuses for public repositories. Also |
| * required for starring public repositories. |
| */ |
| PUBLIC_REPO("public_repo", "Read/write to public repositories"), |
| |
| /** |
| * Grants read/write access to code, commit statuses, collaborators, and |
| * deployment statuses for public and private repositories and |
| * organizations. |
| */ |
| REPO("repo", "Read/write to all public/private repositories"), |
| |
| /** |
| * Grants access to deployment statuses for public and private repositories. |
| * This scope is only necessary to grant other users or services access to |
| * deployment statuses, without granting access to the code. |
| */ |
| REPO_DEPLOYMENT("repo_deployment", "Read-only deployment statuses"), |
| |
| /** |
| * Grants read/write access to public and private repository commit |
| * statuses. This scope is only necessary to grant other users or services |
| * access to private repository commit statuses without granting access to |
| * the code. |
| */ |
| REPO_STATUS("repo:status", "Read/write commit statuses"), |
| |
| /** |
| * Grants access to delete admin-able repositories. |
| */ |
| DELETE_REPO("delete_repo", "Delete repositories"), |
| |
| /** |
| * Grants read access to a user’s notifications. repo also provides this |
| * access. |
| */ |
| NOTIFICATIONS("notifications", "Read user's notifications"), |
| |
| /** |
| * Grants write access to gists. |
| */ |
| GIST("gist", "Write Gists"), |
| |
| /** |
| * Grants read and ping access to hooks in public or private repositories. |
| */ |
| READ_REPO_HOOK("read:repo_hook", |
| "Read/ping public/private repositories' hooks"), |
| |
| /** |
| * Grants read, write, and ping access to hooks in public or private |
| * repositories. |
| */ |
| WRITE_REPO_HOOK("write:repo_hook", |
| "Read/write/ping public/private repositories' hooks"), |
| |
| /** |
| * Grants read, write, ping, and delete access to hooks in public or private |
| * repositories. |
| */ |
| ADMIN_REPO_HOOK("admin:repo_hook", |
| "Read/write/ping/delete access to public/private repositories' hooks"), |
| |
| /** |
| * Grants read, write, ping, and delete access to organization hooks. Note: |
| * OAuth tokens will only be able to perform these actions on organization |
| * hooks which were created by the OAuth application. Personal access tokens |
| * will only be able to perform these actions on organization hooks created |
| * by a user. |
| */ |
| ADMIN_ORG_HOOK("admin:org_hook", |
| "Read/write/ping/delete access to public/private organizations' hooks"), |
| |
| /** |
| * Read-only access to organization, teams, and membership. |
| */ |
| READ_ORG("read:org", "Read-only organizations"), |
| |
| /** |
| * Publicize and un-publicize organization membership. |
| */ |
| WRITE_ORG("write:org", "(Un)Publicize organizations membership"), |
| |
| /** |
| * Fully manage organization, teams, and memberships. |
| */ |
| ADMIN_ORG("admin:org", "Manage organizations, teams and memberships"), |
| |
| /** |
| * List and view details for public keys. |
| */ |
| READ_PUBLIC_KEY("read:public_key", "Read-only user's public keys"), |
| |
| /** |
| * Create, list, and view details for public keys. |
| */ |
| WRITE_PUBLIC_KEY("write:public_key", "Read/write/list user's public keys"), |
| |
| /** |
| * Fully manage public keys. |
| */ |
| ADMIN_PUBLIC_KEY("admin:public_key", "Fully manage user's public keys"); |
| |
| @Getter |
| private final String value; |
| |
| @Getter |
| private final String description; |
| |
| private Scope(final String value, final String description) { |
| this.value = value; |
| this.description = description; |
| } |
| } |
| private static final String ME_SEPARATOR = ","; |
| private static final Logger log = LoggerFactory |
| .getLogger(OAuthProtocol.class); |
| private static final String FINAL_URL_PARAM = "final"; |
| private static SecureRandom randomState = newRandomGenerator(); |
| |
| private final GitHubOAuthConfig config; |
| private final Gson gson; |
| private final Provider<HttpClient> httpProvider; |
| |
| public static class AccessToken { |
| public String accessToken; |
| public String tokenType; |
| public String error; |
| public String errorDescription; |
| public String errorUri; |
| public String raw; |
| |
| public AccessToken() { |
| } |
| |
| public AccessToken(String token) { |
| this(token, ""); |
| } |
| |
| public AccessToken(String token, String type) { |
| this(); |
| this.accessToken = token; |
| this.tokenType = type; |
| } |
| |
| @Override |
| public String toString() { |
| if (isError()) { |
| return "Error AccessToken [error=" + error + ", error_description=" |
| + errorDescription + ", error_uri=" + errorUri + "]"; |
| } |
| return "AccessToken [access_token=" + accessToken + ", token_type=" |
| + tokenType + "]"; |
| } |
| |
| @Override |
| public int hashCode() { |
| return Objects.hash(accessToken, tokenType, error, errorDescription, |
| errorUri); |
| } |
| |
| @Override |
| public boolean equals(Object obj) { |
| if (obj == null) { |
| return false; |
| } |
| if (getClass() != obj.getClass()) { |
| return false; |
| } |
| final AccessToken other = (AccessToken) obj; |
| return Objects.equals(this.accessToken, other.accessToken) |
| && Objects.equals(this.raw, other.raw) |
| && Objects.equals(this.tokenType, other.tokenType); |
| } |
| |
| public boolean isError() { |
| return !Strings.isNullOrEmpty(error); |
| } |
| |
| public String getRaw() { |
| return raw; |
| } |
| |
| public void setRaw(String raw) { |
| this.raw = raw; |
| } |
| |
| public OAuthToken toOAuthToken() { |
| return new OAuthToken(accessToken, null, getRaw()); |
| } |
| } |
| |
| @Inject |
| public OAuthProtocol(GitHubOAuthConfig config, |
| PooledHttpClientProvider httpClientProvider, |
| /* |
| * We need to explicitly tell Guice which Provider<> we need as this class |
| * may be instantiated outside the standard Guice Module set-up (e.g. |
| * initial Servlet login filter) |
| */ |
| GsonProvider gsonProvider) { |
| this.config = config; |
| this.httpProvider = httpClientProvider; |
| this.gson = gsonProvider.get(); |
| } |
| |
| private static SecureRandom newRandomGenerator() { |
| try { |
| return SecureRandom.getInstance("SHA1PRNG"); |
| } catch (NoSuchAlgorithmException e) { |
| throw new IllegalArgumentException( |
| "No SecureRandom available for GitHub authentication", e); |
| } |
| } |
| |
| public String getAuthorizationUrl(String scopesString, String state) { |
| return config.gitHubOAuthUrl + "?client_id=" + config.gitHubClientId |
| + getURLEncodedParameter("&scope=", scopesString) |
| + getURLEncodedParameter("&redirect_uri=", config.oAuthFinalRedirectUrl) |
| + getURLEncodedParameter("&state=", state); |
| } |
| |
| public String loginPhase1(HttpServletRequest request, |
| HttpServletResponse response, Set<Scope> scopes) throws IOException { |
| String scopesString = getScope(scopes); |
| log.debug("Initiating GitHub Login for ClientId=" + config.gitHubClientId |
| + " Scopes=" + scopesString); |
| String state = newRandomState(request.getRequestURI().toString()); |
| log.debug("Initiating GitHub Login for ClientId=" + config.gitHubClientId + " Scopes=" + scopesString); |
| response.sendRedirect(getAuthorizationUrl(scopesString, state)); |
| return state; |
| } |
| |
| public String getScope(Set<Scope> scopes) { |
| if(scopes.size() <= 0) { |
| return ""; |
| } |
| |
| StringBuilder out = new StringBuilder(); |
| for (Scope scope : scopes) { |
| if (out.length() > 0) { |
| out.append(","); |
| } |
| out.append(scope.getValue()); |
| } |
| return out.toString(); |
| } |
| |
| public static boolean isOAuthFinal(HttpServletRequest request) { |
| return Strings.emptyToNull(request.getParameter("code")) != null |
| && request.getParameter(FINAL_URL_PARAM) == null; |
| } |
| |
| public static boolean isOAuthFinalForOthers(HttpServletRequest request) { |
| String targetUrl = getTargetUrl(request); |
| if (targetUrl.equals(request.getRequestURI())) { |
| return false; |
| } |
| |
| return Strings.emptyToNull(request.getParameter("code")) != null |
| && request.getParameter(FINAL_URL_PARAM) == null; |
| } |
| |
| public String newRandomState(String redirectUrl) { |
| byte[] stateBin = new byte[20]; // SHA-1 size |
| randomState.nextBytes(stateBin); |
| return BaseEncoding.base64Url().encode(stateBin) + ME_SEPARATOR + redirectUrl; |
| } |
| |
| public static boolean isOAuthLogin(HttpServletRequest request) { |
| String requestUri = request.getRequestURI(); |
| return requestUri.indexOf(GitHubOAuthConfig.GERRIT_LOGIN) >= 0 |
| && request.getParameter(FINAL_URL_PARAM) == null; |
| } |
| |
| public static boolean isOAuthLogout(HttpServletRequest request) { |
| return request.getRequestURI().indexOf(GitHubOAuthConfig.GERRIT_LOGOUT) >= 0; |
| } |
| |
| public static boolean isOAuthRequest(HttpServletRequest httpRequest) { |
| return OAuthProtocol.isOAuthLogin(httpRequest) || OAuthProtocol.isOAuthFinal(httpRequest); |
| } |
| |
| public AccessToken loginPhase2(HttpServletRequest request, |
| HttpServletResponse response, String state) throws IOException { |
| String requestState = request.getParameter("state"); |
| if (!Objects.equals(state, requestState)) { |
| throw new IOException("Invalid authentication state: expected '" + state |
| + "' but was '" + requestState + "'"); |
| } |
| |
| return getAccessToken(new OAuthVerifier(request.getParameter("code"))); |
| } |
| |
| public AccessToken getAccessToken(OAuthVerifier code) throws IOException { |
| HttpPost post = new HttpPost(config.gitHubOAuthAccessTokenUrl); |
| post.setHeader("Accept", "application/json"); |
| List<NameValuePair> nvps = new ArrayList<>(); |
| nvps.add(new BasicNameValuePair("client_id", config.gitHubClientId)); |
| nvps.add(new BasicNameValuePair("client_secret", config.gitHubClientSecret)); |
| nvps.add(new BasicNameValuePair("code", code.getValue())); |
| post.setEntity(new UrlEncodedFormEntity(nvps, Charsets.UTF_8)); |
| |
| HttpResponse postResponse = httpProvider.get().execute(post); |
| if (postResponse.getStatusLine().getStatusCode() != HttpURLConnection.HTTP_OK) { |
| log.error("POST " + config.gitHubOAuthAccessTokenUrl |
| + " request for access token failed with status " |
| + postResponse.getStatusLine()); |
| EntityUtils.consume(postResponse.getEntity()); |
| throw new IOException("GitHub OAuth request failed"); |
| } |
| |
| InputStream content = postResponse.getEntity().getContent(); |
| String tokenJsonString = |
| CharStreams.toString(new InputStreamReader(content, |
| StandardCharsets.UTF_8)); |
| AccessToken token = gson.fromJson(tokenJsonString, AccessToken.class); |
| if (token.isError()) { |
| log.error("POST " + config.gitHubOAuthAccessTokenUrl |
| + " returned an error token: " + token); |
| throw new IOException("Invalid GitHub OAuth token"); |
| } |
| |
| return token; |
| } |
| |
| private static String getURLEncodedParameter(String prefix, String url) { |
| try { |
| return Strings.isNullOrEmpty(url) ? |
| "" : (prefix + URLEncoder.encode(url,"UTF-8")); |
| } catch (UnsupportedEncodingException e) { |
| throw new IllegalStateException("Cannot find UTF-8 encoding", e); |
| } |
| } |
| |
| public static String getTargetUrl(ServletRequest request) { |
| int meEnd = state(request).indexOf(ME_SEPARATOR); |
| String finalUrlSuffix = "?" + FINAL_URL_PARAM + "=true"; |
| if (meEnd > 0) { |
| return state(request).substring(meEnd + 1) + finalUrlSuffix; |
| } |
| return finalUrlSuffix; |
| } |
| |
| private static String state(ServletRequest request) { |
| return Strings.nullToEmpty(request.getParameter("state")); |
| } |
| |
| public static String getTargetOAuthFinal(HttpServletRequest httpRequest) { |
| String targetUrl = getTargetUrl(httpRequest); |
| String code = getURLEncodedParameter("code=", httpRequest.getParameter("code")); |
| String state = getURLEncodedParameter("&state=", httpRequest.getParameter("state")); |
| return targetUrl + (targetUrl.indexOf('?') < 0 ? '?' : '&') + code + state; |
| } |
| |
| @Override |
| public String toString() { |
| return "OAuthProtocol"; |
| } |
| |
| } |