diff --git a/Documentation/config-gerrit.txt b/Documentation/config-gerrit.txt index fb5bc153b4..99c158f94b 100644 --- a/Documentation/config-gerrit.txt +++ b/Documentation/config-gerrit.txt @@ -145,7 +145,7 @@ request is the exact string supplied in the dialog by the user. The configured <> 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 diff --git a/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java new file mode 100644 index 0000000000..3fa7bb2799 --- /dev/null +++ b/gerrit-extension-api/src/main/java/com/google/gerrit/extensions/auth/oauth/OAuthLoginProvider.java @@ -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; +} diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java index e1810efb55..ab6cc90998 100644 --- a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java +++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/GitOverHttpModule.java @@ -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; } diff --git a/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java new file mode 100644 index 0000000000..c7158d3f00 --- /dev/null +++ b/gerrit-httpd/src/main/java/com/google/gerrit/httpd/ProjectOAuthFilter.java @@ -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 RFC 6750 + */ +@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 session; + private final DynamicMap 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 session, + DynamicMap 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 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 + * auth.gitOAuthProvider. + * + * @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); + } + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java index 35ab3af72f..35d7c70cdf 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/account/AuthRequest.java @@ -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. *

@@ -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; + } } diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java new file mode 100644 index 0000000000..582cc38a5a --- /dev/null +++ b/gerrit-server/src/main/java/com/google/gerrit/server/auth/oauth/OAuthRealm.java @@ -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 loginProviders; + + @Inject + OAuthRealm(DynamicMap 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; + } +} diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java index ca4a9d2c0d..a6623288b2 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/AuthModule.java @@ -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; diff --git a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java index 6b93a68830..564e0fb53e 100644 --- a/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java +++ b/gerrit-server/src/main/java/com/google/gerrit/server/config/GerritGlobalModule.java @@ -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);