| 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.gerrit.extensions.auth.oauth.OAuthToken; |
| import com.google.gerrit.extensions.auth.oauth.OAuthVerifier; |
| import com.google.gson.Gson; |
| import com.google.inject.Inject; |
| import com.google.inject.Provider; |
| import com.google.inject.Singleton; |
| 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 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; |
| |
| @Singleton |
| public class OAuthProtocol { |
| /** |
| * Supported OAuth Scopes. |
| * |
| * <p>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, HttpServletRequest req) { |
| return config.gitHubOAuthUrl |
| + "?client_id=" |
| + config.gitHubClientId |
| + getURLEncodedParameter("&scope=", scopesString) |
| + getURLEncodedParameter("&redirect_uri=", config.getOAuthFinalRedirectUrl(req)) |
| + 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, request)); |
| 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"; |
| } |
| } |