blob: 6e4d500aea05def71a5b4ea0295b7f7675c90443 [file] [log] [blame]
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 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", "Access e-mail addresses"),
/** 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 and push to public repos"),
/**
* Grants read/write access to code, commit statuses, collaborators, and deployment statuses for
* public and private repositories and organizations.
*/
REPO("repo", "Read and push to public and private repos"),
/**
* 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 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 commit statuses"),
/** Grants access to delete admin-able repositories. */
DELETE_REPO("delete_repo", "Delete repos"),
/** Grants read access to a user’s notifications. repo also provides this access. */
NOTIFICATIONS("notifications", "Read 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 repos' 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 repos' 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 repos' 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 membership to public/private 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 owned public keys"),
/** Create, list, and view details for public keys. */
WRITE_PUBLIC_KEY("write:public_key", "Read/write/list owned public keys"),
/** Fully manage public keys. */
ADMIN_PUBLIC_KEY("admin:public_key", "Fully manage owned 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";
}
}