OAuth2 authentication for Git-over-HTTP

OAuth2 support was only implemented for the web UI so far
but not for Git-over-HTTP communication. This patch adds
a mechanism similiar to that supported by Github,
where Git clients may send OAuth2 access tokens instead
of passwords in a Basic authentication header [1].

Received access tokens will be verified by means of an
OAuthLoginProvider, which is a new extension point.
The OAuth2 protocol does not specify a mechanism for how to
verify access tokens, so there is no default implementation
for the OAuthLoginProvider interface, but a plugin must
provide a suitable implementation.

In order to enable OAuth2 authentication for Git-over-HTTP
the configuration option auth.type must be set to OAUTH
and auth.gitBasicAuth must be set to true. The parameter
auth.gitOAuthProvider defines the default OAuthLoginProvider
to use in case multiple OAuthLoginProvider implementations
are installed and it cannot be deduced from the request,
which OAuth provider to address.

An OAuthLoginProvider implementation may also support
HTTP Basic authentication with passwords instead of access
tokens, if the OAuth2 backend supports the Resource Owner
Password Credentials Grant authentication flow [2] or some
other API for verifying password credentials. For that reason
the second parameter of the OAuthLoginProvider interface is
called "secret" instead of "accessToken".

An example implementation for the OAuthLoginProvider
extension point will be contributed to the cfoauth plugin.

[1] https://developer.github.com/v3/auth/#basic-authentication
[2] https://tools.ietf.org/html/rfc6749#section-4.3

Change-Id: I0f00599dce38a806fd3e21758ea9e2cab49ce57f
Signed-off-by: Michael Ochmann <michael.ochmann@sap.com>
This commit is contained in:
Michael Ochmann
2015-10-20 15:34:29 +02:00
parent 386ff83c94
commit e9e046a4b3
8 changed files with 516 additions and 5 deletions

View File

@@ -145,7 +145,7 @@ request is the exact string supplied in the dialog by the user.
The configured <<ldap.username,ldap.username>> identity is not used to obtain
account information.
+
* OAUTH
* `OAUTH`
+
OAuth is a protocol that lets external apps request authorization to private
details in a user's account without getting their password. This is
@@ -437,9 +437,9 @@ By default this is set to false.
[[auth.gitBasicAuth]]auth.gitBasicAuth::
+
If true then Git over HTTP and HTTP/S traffic is authenticated using
standard BasicAuth and the credentials are validated against the randomly
generated HTTP password or against LDAP when it is configured as Gerrit
Web UI authentication method.
standard BasicAuth. Depending on the configured `auth.type` credentials
are validated against the randomly generated HTTP password, against LDAP
(`auth.type = LDAP`) or against an OAuth 2 provider (`auth.type = OAUTH`).
+
This parameter affects git over HTTP traffic and access to the REST
API. If set to false then Gerrit will authenticate through DIGEST
@@ -449,8 +449,30 @@ database.
When `auth.type` is `LDAP`, service users that only exist in the Gerrit
database are still authenticated by their HTTP passwords.
+
When `auth.type` is `OAUTH`, Git clients may send OAuth 2 access tokens
instead of passwords in the Basic authentication header. Note that provider
specific plugins must be installed to facilitate this authentication scheme.
If multiple OAuth 2 provider plugins are installed one of them must be
selected as default with the `auth.gitOAuthProvider` option.
+
By default this is set to false.
[[auth.gitOAuthProvider]]auth.gitOAuthProvider::
+
Selects the OAuth 2 provider to authenticate git over HTTP traffic with.
+
In general there is no way to determine from an access token alone, which
OAuth 2 provider to address to verify that token, and the BasicAuth
scheme does not support amending such details. If multiple OAuth provider
plugins in a system offer support for git over HTTP authentication site
administrators must configure, which one to use as default provider.
In case the provider cannot be determined from a request the access token
will be sent to the default provider for verification.
+
The value of this parameter must be the identifier of an OAuth 2 provider
in the form `plugin-name:provider-name`. Consult the respective plugin
documentation for details.
[[auth.userNameToLowerCase]]auth.userNameToLowerCase::
+
If set the username that is received to authenticate a git operation

View File

@@ -0,0 +1,42 @@
// Copyright (C) 2015 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.extensions.auth.oauth;
import com.google.gerrit.extensions.annotations.ExtensionPoint;
import java.io.IOException;
@ExtensionPoint
public interface OAuthLoginProvider {
/**
* Performs a login with an OAuth2 provider for Git over HTTP
* communication.
*
* An implementation of this interface must transmit the given
* user name and secret, which can be either an OAuth2 access token
* or a password, to the OAuth2 backend for verification.
*
* @param username the user's identifier.
* @param secret the secret to verify, e.g. a previously received
* access token or a password.
*
* @return information about the logged in user, at least
* external id, user name and email address.
*
* @throws IOException if the login failed.
*/
OAuthUserInfo login(String username, String secret) throws IOException;
}

View File

@@ -14,6 +14,8 @@
package com.google.gerrit.httpd;
import static com.google.gerrit.reviewdb.client.AuthType.OAUTH;
import com.google.gerrit.reviewdb.client.CoreDownloadSchemes;
import com.google.gerrit.server.config.AuthConfig;
import com.google.gerrit.server.config.DownloadConfig;
@@ -40,7 +42,11 @@ public class GitOverHttpModule extends ServletModule {
if (authConfig.isTrustContainerAuth()) {
authFilter = ContainerAuthFilter.class;
} else if (authConfig.isGitBasicAuth()) {
authFilter = ProjectBasicAuthFilter.class;
if (authConfig.getAuthType() == OAUTH) {
authFilter = ProjectOAuthFilter.class;
} else {
authFilter = ProjectBasicAuthFilter.class;
}
} else {
authFilter = ProjectDigestFilter.class;
}

View File

@@ -0,0 +1,285 @@
// Copyright (C) 2015 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.httpd;
import static java.nio.charset.StandardCharsets.UTF_8;
import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
import com.google.common.base.MoreObjects;
import com.google.common.base.Strings;
import com.google.common.collect.Iterables;
import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.registration.DynamicMap.Entry;
import com.google.gerrit.server.AccessPath;
import com.google.gerrit.server.account.AccountCache;
import com.google.gerrit.server.account.AccountException;
import com.google.gerrit.server.account.AccountManager;
import com.google.gerrit.server.account.AccountState;
import com.google.gerrit.server.account.AuthRequest;
import com.google.gerrit.server.account.AuthResult;
import com.google.gerrit.server.config.GerritServerConfig;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import org.apache.commons.codec.binary.Base64;
import org.eclipse.jgit.lib.Config;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.util.Locale;
import java.util.NoSuchElementException;
import javax.servlet.Filter;
import javax.servlet.FilterChain;
import javax.servlet.FilterConfig;
import javax.servlet.ServletException;
import javax.servlet.ServletRequest;
import javax.servlet.ServletResponse;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
import javax.servlet.http.HttpServletResponseWrapper;
/**
* Authenticates the current user with an OAuth2 server.
*
* @see <a href="https://tools.ietf.org/rfc/rfc6750.txt">RFC 6750</a>
*/
@Singleton
class ProjectOAuthFilter implements Filter {
private static final Logger log = LoggerFactory
.getLogger(ProjectOAuthFilter.class);
private static final String REALM_NAME = "Gerrit Code Review";
private static final String AUTHORIZATION = "Authorization";
private static final String BASIC = "Basic ";
private final DynamicItem<WebSession> session;
private final DynamicMap<OAuthLoginProvider> loginProviders;
private final AccountCache accountCache;
private final AccountManager accountManager;
private final String gitOAuthProvider;
private final boolean userNameToLowerCase;
private String defaultAuthPlugin;
private String defaultAuthProvider;
@Inject
ProjectOAuthFilter(DynamicItem<WebSession> session,
DynamicMap<OAuthLoginProvider> pluginsProvider,
AccountCache accountCache,
AccountManager accountManager,
@GerritServerConfig Config gerritConfig) {
this.session = session;
this.loginProviders = pluginsProvider;
this.accountCache = accountCache;
this.accountManager = accountManager;
this.gitOAuthProvider =
gerritConfig.getString("auth", null, "gitOAuthProvider");
this.userNameToLowerCase =
gerritConfig.getBoolean("auth", null, "userNameToLowerCase", false);
}
@Override
public void init(FilterConfig config) throws ServletException {
if (Strings.isNullOrEmpty(gitOAuthProvider)) {
pickOnlyProvider();
} else {
pickConfiguredProvider();
}
}
@Override
public void destroy() {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest req = (HttpServletRequest) request;
Response rsp = new Response((HttpServletResponse) response);
if (verify(req, rsp)) {
chain.doFilter(req, rsp);
}
}
private boolean verify(HttpServletRequest req, Response rsp)
throws IOException {
String hdr = req.getHeader(AUTHORIZATION);
if (hdr == null || !hdr.startsWith(BASIC)) {
// Allow an anonymous connection through, or it might be using a
// session cookie instead of basic authentication.
return true;
}
byte[] decoded = Base64.decodeBase64(hdr.substring(BASIC.length()));
String usernamePassword = new String(decoded, encoding(req));
int splitPos = usernamePassword.indexOf(':');
if (splitPos < 1) {
rsp.sendError(SC_UNAUTHORIZED);
return false;
}
AuthInfo authInfo = new AuthInfo(usernamePassword.substring(0, splitPos),
usernamePassword.substring(splitPos + 1),
defaultAuthPlugin, defaultAuthProvider);
if (Strings.isNullOrEmpty(authInfo.tokenOrSecret)) {
rsp.sendError(SC_UNAUTHORIZED);
return false;
}
AccountState who = accountCache.getByUsername(authInfo.username);
if (who == null || !who.getAccount().isActive()) {
log.warn("Authentication failed for " + authInfo.username
+ ": account inactive or not provisioned in Gerrit");
rsp.sendError(SC_UNAUTHORIZED);
return false;
}
AuthRequest authRequest = AuthRequest.forExternalUser(
authInfo.username);
authRequest.setEmailAddress(who.getAccount().getPreferredEmail());
authRequest.setDisplayName(who.getAccount().getFullName());
authRequest.setPassword(authInfo.tokenOrSecret);
authRequest.setAuthPlugin(authInfo.pluginName);
authRequest.setAuthProvider(authInfo.exportName);
try {
AuthResult authResult = accountManager.authenticate(authRequest);
WebSession ws = session.get();
ws.setUserAccountId(authResult.getAccountId());
ws.setAccessPathOk(AccessPath.GIT, true);
ws.setAccessPathOk(AccessPath.REST_API, true);
return true;
} catch (AccountException e) {
log.warn("Authentication failed for " + authInfo.username, e);
rsp.sendError(SC_UNAUTHORIZED);
return false;
}
}
/**
* Picks the only installed OAuth provider. If there is a multiude
* of providers available, the actual provider must be determined
* from the authentication request.
*
* @throws ServletException if there is no {@code OAuthLoginProvider}
* installed at all.
*/
private void pickOnlyProvider() throws ServletException {
try {
Entry<OAuthLoginProvider> loginProvider =
Iterables.getOnlyElement(loginProviders);
defaultAuthPlugin = loginProvider.getPluginName();
defaultAuthProvider = loginProvider.getExportName();
} catch (NoSuchElementException e) {
throw new ServletException("No OAuth login provider installed");
} catch (IllegalArgumentException e) {
// multiple providers found => do not pick any
}
}
/**
* Picks the {@code OAuthLoginProvider} configured with
* <tt>auth.gitOAuthProvider</tt>.
*
* @throws ServletException if the configured provider was not found.
*/
private void pickConfiguredProvider() throws ServletException {
int splitPos = gitOAuthProvider.lastIndexOf(':');
if (splitPos < 1 || splitPos == gitOAuthProvider.length() - 1) {
// no colon at all or leading/trailing colon: malformed providerId
throw new ServletException("OAuth login provider configuration is"
+ " invalid: Must be of the form pluginName:providerName");
}
defaultAuthPlugin= gitOAuthProvider.substring(0, splitPos);
defaultAuthProvider = gitOAuthProvider.substring(splitPos + 1);
OAuthLoginProvider provider = loginProviders.get(defaultAuthPlugin,
defaultAuthProvider);
if (provider == null) {
throw new ServletException("Configured OAuth login provider "
+ gitOAuthProvider + " wasn't installed");
}
}
private static String encoding(HttpServletRequest req) {
return MoreObjects.firstNonNull(req.getCharacterEncoding(), UTF_8.name());
}
private class AuthInfo {
private final String username;
private final String tokenOrSecret;
private final String pluginName;
private final String exportName;
private AuthInfo(String username, String tokenOrSecret,
String pluginName, String exportName) {
this.username = userNameToLowerCase
? username.toLowerCase(Locale.US)
: username;
this.tokenOrSecret = tokenOrSecret;
this.pluginName = pluginName;
this.exportName = exportName;
}
}
private static class Response extends HttpServletResponseWrapper {
private static final String WWW_AUTHENTICATE = "WWW-Authenticate";
Response(HttpServletResponse rsp) {
super(rsp);
}
private void status(int sc) {
if (sc == SC_UNAUTHORIZED) {
StringBuilder v = new StringBuilder();
v.append(BASIC);
v.append("realm=\"").append(REALM_NAME).append("\"");
setHeader(WWW_AUTHENTICATE, v.toString());
} else if (containsHeader(WWW_AUTHENTICATE)) {
setHeader(WWW_AUTHENTICATE, null);
}
}
@Override
public void sendError(int sc, String msg) throws IOException {
status(sc);
super.sendError(sc, msg);
}
@Override
public void sendError(int sc) throws IOException {
status(sc);
super.sendError(sc);
}
@Override
@Deprecated
public void setStatus(int sc, String sm) {
status(sc);
super.setStatus(sc, sm);
}
@Override
public void setStatus(int sc) {
status(sc);
super.setStatus(sc);
}
}
}

View File

@@ -16,6 +16,7 @@ package com.google.gerrit.server.account;
import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_GERRIT;
import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_MAILTO;
import static com.google.gerrit.reviewdb.client.AccountExternalId.SCHEME_EXTERNAL;
import com.google.gerrit.reviewdb.client.AccountExternalId;
@@ -38,6 +39,15 @@ public class AuthRequest {
return r;
}
/** Create a request for an external username. */
public static AuthRequest forExternalUser(String username) {
AccountExternalId.Key i =
new AccountExternalId.Key(SCHEME_EXTERNAL, username);
AuthRequest r = new AuthRequest(i.get());
r.setUserName(username);
return r;
}
/**
* Create a request for an email address registration.
* <p>
@@ -58,6 +68,8 @@ public class AuthRequest {
private String emailAddress;
private String userName;
private boolean skipAuthentication;
private String authPlugin;
private String authProvider;
public AuthRequest(final String externalId) {
this.externalId = externalId;
@@ -125,4 +137,20 @@ public class AuthRequest {
public void setSkipAuthentication(boolean skip) {
skipAuthentication = skip;
}
public String getAuthPlugin() {
return authPlugin;
}
public void setAuthPlugin(String authPlugin) {
this.authPlugin = authPlugin;
}
public String getAuthProvider() {
return authProvider;
}
public void setAuthProvider(String authProvider) {
this.authProvider = authProvider;
}
}

View File

@@ -0,0 +1,121 @@
// Copyright (C) 2015 The Android Open Source Project
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.
package com.google.gerrit.server.auth.oauth;
import com.google.common.base.Strings;
import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.reviewdb.client.Account.FieldName;
import com.google.gerrit.reviewdb.server.ReviewDb;
import com.google.gerrit.server.account.AbstractRealm;
import com.google.gerrit.server.account.AccountException;
import com.google.gerrit.server.account.AccountManager;
import com.google.gerrit.server.account.AuthRequest;
import com.google.inject.Inject;
import com.google.inject.Singleton;
import java.io.IOException;
@Singleton
public class OAuthRealm extends AbstractRealm {
private final DynamicMap<OAuthLoginProvider> loginProviders;
@Inject
OAuthRealm(DynamicMap<OAuthLoginProvider> loginProviders) {
this.loginProviders = loginProviders;
}
@Override
public boolean allowsEdit(FieldName field) {
return false;
}
/**
* Authenticates with the {@link OAuthLoginProvider} specified
* in the authentication request.
*
* {@link AccountManager} calls this method without password
* if authenticity of the user has already been established.
* In that case the {@link AuthRequest} is supposed to contain
* a resolved email address and we can skip the authentication
* request to the {@code OAuthLoginService}.
*
* @param who the authentication request.
*
* @return the authentication request with resolved email address
* and display name in case the authenticity of the user could
* be established; otherwise {@code who} is returned unchanged.
*
* @throws AccountException if the authentication request with
* the OAuth2 server failed or no {@code OAuthLoginProvider} was
* available to handle the request.
*/
@Override
public AuthRequest authenticate(AuthRequest who) throws AccountException {
if (Strings.isNullOrEmpty(who.getPassword()) &&
!Strings.isNullOrEmpty(who.getEmailAddress())) {
return who;
}
if (Strings.isNullOrEmpty(who.getAuthPlugin())
|| Strings.isNullOrEmpty(who.getAuthProvider())) {
throw new AccountException("Cannot authenticate");
}
OAuthLoginProvider loginProvider =
loginProviders.get(who.getAuthPlugin(), who.getAuthProvider());
if (loginProvider == null) {
throw new AccountException("Cannot authenticate");
}
OAuthUserInfo userInfo;
try {
userInfo = loginProvider.login(who.getUserName(), who.getPassword());
} catch (IOException e) {
throw new AccountException("Cannot authenticate", e);
}
if (userInfo == null) {
throw new AccountException("Cannot authenticate");
}
if (!Strings.isNullOrEmpty(userInfo.getEmailAddress())) {
who.setEmailAddress(userInfo.getEmailAddress());
}
if (!Strings.isNullOrEmpty(userInfo.getDisplayName())) {
who.setDisplayName(userInfo.getDisplayName());
}
return who;
}
@Override
public AuthRequest link(ReviewDb db, Account.Id to, AuthRequest who) {
return who;
}
@Override
public AuthRequest unlink(ReviewDb db, Account.Id to, AuthRequest who)
throws AccountException {
return who;
}
@Override
public void onCreateAccount(AuthRequest who, Account account) {
}
@Override
public Account.Id lookup(String accountName) {
return null;
}
}

View File

@@ -21,6 +21,7 @@ import com.google.gerrit.server.account.Realm;
import com.google.gerrit.server.auth.AuthBackend;
import com.google.gerrit.server.auth.InternalAuthBackend;
import com.google.gerrit.server.auth.ldap.LdapModule;
import com.google.gerrit.server.auth.oauth.OAuthRealm;
import com.google.inject.AbstractModule;
import com.google.inject.Inject;
@@ -42,6 +43,10 @@ public class AuthModule extends AbstractModule {
install(new LdapModule());
break;
case OAUTH:
bind(Realm.class).to(OAuthRealm.class);
break;
case CUSTOM_EXTENSION:
break;

View File

@@ -19,6 +19,7 @@ import static com.google.inject.Scopes.SINGLETON;
import com.google.common.cache.Cache;
import com.google.gerrit.audit.AuditModule;
import com.google.gerrit.common.EventListener;
import com.google.gerrit.extensions.auth.oauth.OAuthLoginProvider;
import com.google.gerrit.extensions.config.CapabilityDefinition;
import com.google.gerrit.extensions.config.CloneCommand;
import com.google.gerrit.extensions.config.DownloadCommand;
@@ -296,6 +297,7 @@ public class GerritGlobalModule extends FactoryModule {
DynamicSet.setOf(binder(), DiffWebLink.class);
DynamicSet.setOf(binder(), ProjectWebLink.class);
DynamicSet.setOf(binder(), BranchWebLink.class);
DynamicMap.mapOf(binder(), OAuthLoginProvider.class);
factory(UploadValidators.Factory.class);
DynamicSet.setOf(binder(), UploadValidationListener.class);