Support hybrid OpenID and OAuth2 authentication

e9707d8f85 exposed OAuth authentication extension point. Using this
extension point plugins can offer OAuth2 authentications.

That is fine for new Gerrit sites, which can restrict the auth scheme
to OAuth2 only.

For the existing sites, that rely on non SSO OpenID auth scheme it
doesn't work to migrate to OAuth2 because of diverse contributors
base that use different OpenID providers. Not all OpenID providers
offer OAuth2 protocol. Particularly, widespread OpenID providers
among open source Gerrit communities are Launchpad/UbuntuOne and
FedoraProject don't offer OAuth2 protocol. To not lock out those
contributors from being able to contribute to open source Gerrit
based projects OpenID must still be supported.

With Google's shut down of their OpenID service in April 2015, big
user base is locked out from contribution to Gerrit based projects
that only support OpenID auth scheme.

The only way to still support OpenID 2.0 providers and new OAuth2
based protocol is native support for hybrid authentication scheme
in Gerrit.

This change extends OpenID auth scheme by making it aware of optional
OAuth plugin-based authentication.

When no oauth-provider plugins are deployed, OpenID auth scheme works
as usual. When OAuth provider plugins are deployed, OAuth2 providers
are offered on the OpenID login form, in addition to hard coded Yahoo!
and Launchpad OpenID providers: [1].

[1] http://imgur.com/IcCrChN

Change-Id: I6d70212f4fea5443a6322c7da683e1e943d058eb
This commit is contained in:
David Ostrovsky 2015-03-16 08:16:45 +01:00 committed by Saša Živkov
parent b4c5a0e23f
commit 8b5aa48f1d
8 changed files with 493 additions and 5 deletions

View File

@ -86,7 +86,8 @@ class UrlModule extends ServletModule {
}
serve("/cat/*").with(CatServlet.class);
if (authConfig.getAuthType() != AuthType.OAUTH) {
if (authConfig.getAuthType() != AuthType.OAUTH &&
authConfig.getAuthType() != AuthType.OPENID) {
serve("/logout").with(HttpLogoutServlet.class);
serve("/signout").with(HttpLogoutServlet.class);
}

View File

@ -12,6 +12,7 @@ java_library(
'//gerrit-server:server',
'//lib:guava',
'//lib:gwtorm',
'//lib/commons:codec',
'//lib/guice:guice',
'//lib/guice:guice-servlet',
'//lib/jgit:jgit',

View File

@ -22,11 +22,14 @@ import com.google.common.collect.Sets;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.common.PageLinks;
import com.google.gerrit.common.auth.openid.OpenIdUrls;
import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.extensions.restapi.Url;
import com.google.gerrit.httpd.HtmlDomUtil;
import com.google.gerrit.httpd.LoginUrlToken;
import com.google.gerrit.httpd.template.SiteHeaderFooter;
import com.google.gerrit.reviewdb.client.AuthType;
import com.google.gerrit.server.CurrentUser;
import com.google.gerrit.server.config.AuthConfig;
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.gerrit.server.config.GerritServerConfig;
@ -61,10 +64,13 @@ class LoginForm extends HttpServlet {
private final ImmutableSet<String> suggestProviders;
private final Provider<String> urlProvider;
private final Provider<OAuthSessionOverOpenID> oauthSessionProvider;
private final OpenIdServiceImpl impl;
private final int maxRedirectUrlLength;
private final String ssoUrl;
private final SiteHeaderFooter header;
private final Provider<CurrentUser> currentUserProvider;
private final DynamicMap<OAuthServiceProvider> oauthServiceProviders;
@Inject
LoginForm(
@ -72,13 +78,19 @@ class LoginForm extends HttpServlet {
@GerritServerConfig Config config,
AuthConfig authConfig,
OpenIdServiceImpl impl,
SiteHeaderFooter header) {
SiteHeaderFooter header,
Provider<OAuthSessionOverOpenID> oauthSessionProvider,
Provider<CurrentUser> currentUserProvider,
DynamicMap<OAuthServiceProvider> oauthServiceProviders) {
this.urlProvider = urlProvider;
this.impl = impl;
this.header = header;
this.maxRedirectUrlLength = config.getInt(
"openid", "maxRedirectUrlLength",
10);
this.oauthSessionProvider = oauthSessionProvider;
this.currentUserProvider = currentUserProvider;
this.oauthServiceProviders = oauthServiceProviders;
if (urlProvider == null || Strings.isNullOrEmpty(urlProvider.get())) {
log.error("gerrit.canonicalWebUrl must be set in gerrit.config");
@ -152,7 +164,23 @@ class LoginForm extends HttpServlet {
mode = SignInMode.SIGN_IN;
}
discover(req, res, link, id, remember, token, mode);
OAuthServiceProvider oauthProvider = lookupOAuthServiceProvider(id);
if (oauthProvider == null) {
discover(req, res, link, id, remember, token, mode);
} else {
OAuthSessionOverOpenID oauthSession = oauthSessionProvider.get();
if (!currentUserProvider.get().isIdentifiedUser()
&& oauthSession.isLoggedIn()) {
oauthSession.logout();
}
if ((isGerritLogin(req)
|| oauthSession.isOAuthFinal(req))
&& !oauthSession.isLoggedIn()) {
oauthSession.setServiceProvider(oauthProvider);
oauthSession.login(req, res, oauthProvider);
}
}
}
private void discover(HttpServletRequest req, HttpServletResponse res,
@ -266,6 +294,20 @@ class LoginForm extends HttpServlet {
}
a.setAttribute("href", u.toString());
}
// OAuth: Add plugin based providers
Element providers = HtmlDomUtil.find(doc, "providers");
Set<String> plugins = oauthServiceProviders.plugins();
for (String pluginName : plugins) {
Map<String, Provider<OAuthServiceProvider>> m =
oauthServiceProviders.byPlugin(pluginName);
for (Map.Entry<String, Provider<OAuthServiceProvider>> e
: m.entrySet()) {
addProvider(providers, pluginName, e.getKey(),
e.getValue().get().getName());
}
}
sendHtml(res, doc);
}
@ -284,6 +326,38 @@ class LoginForm extends HttpServlet {
}
}
private static void addProvider(Element form, String pluginName,
String id, String serviceName) {
Element div = form.getOwnerDocument().createElement("div");
div.setAttribute("id", id);
Element hyperlink = form.getOwnerDocument().createElement("a");
hyperlink.setAttribute("href", String.format("?id=%s_%s",
pluginName, id));
hyperlink.setTextContent(serviceName +
" (" + pluginName + " plugin)");
div.appendChild(hyperlink);
form.appendChild(div);
}
private OAuthServiceProvider lookupOAuthServiceProvider(String providerId) {
if (providerId.startsWith("http://")) {
providerId = providerId.substring("http://".length());
}
Set<String> plugins = oauthServiceProviders.plugins();
for (String pluginName : plugins) {
Map<String, Provider<OAuthServiceProvider>> m =
oauthServiceProviders.byPlugin(pluginName);
for (Map.Entry<String, Provider<OAuthServiceProvider>> e
: m.entrySet()) {
if (providerId.equals(
String.format("%s_%s", pluginName, e.getKey()))) {
return e.getValue().get();
}
}
}
return null;
}
private static String getLastId(HttpServletRequest req) {
Cookie[] cookies = req.getCookies();
if (cookies != null) {
@ -295,4 +369,9 @@ class LoginForm extends HttpServlet {
}
return null;
}
private static boolean isGerritLogin(HttpServletRequest request) {
return request.getRequestURI().indexOf(
OAuthSessionOverOpenID.GERRIT_LOGIN) >= 0;
}
}

View File

@ -0,0 +1,57 @@
// 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.auth.openid;
import com.google.gerrit.audit.AuditService;
import com.google.gerrit.common.Nullable;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.httpd.HttpLogoutServlet;
import com.google.gerrit.httpd.WebSession;
import com.google.gerrit.server.account.AccountManager;
import com.google.gerrit.server.config.AuthConfig;
import com.google.gerrit.server.config.CanonicalWebUrl;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
@Singleton
class OAuthOverOpenIDLogoutServlet extends HttpLogoutServlet {
private static final long serialVersionUID = 1L;
private final Provider<OAuthSessionOverOpenID> oauthSession;
@Inject
OAuthOverOpenIDLogoutServlet(AuthConfig authConfig,
DynamicItem<WebSession> webSession,
AccountManager accountManager,
@CanonicalWebUrl @Nullable Provider<String> urlProvider,
AuditService audit,
Provider<OAuthSessionOverOpenID> oauthSession) {
super(authConfig, webSession, urlProvider, accountManager, audit);
this.oauthSession = oauthSession;
}
@Override
protected void doLogout(HttpServletRequest req, HttpServletResponse rsp)
throws IOException {
super.doLogout(req, rsp);
oauthSession.get().logout();
}
}

View File

@ -0,0 +1,216 @@
// 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.auth.openid;
import static javax.servlet.http.HttpServletResponse.SC_UNAUTHORIZED;
import com.google.common.base.Strings;
import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
import com.google.gerrit.extensions.auth.oauth.OAuthToken;
import com.google.gerrit.extensions.auth.oauth.OAuthUserInfo;
import com.google.gerrit.extensions.auth.oauth.OAuthVerifier;
import com.google.gerrit.extensions.registration.DynamicItem;
import com.google.gerrit.extensions.restapi.Url;
import com.google.gerrit.httpd.CanonicalWebUrl;
import com.google.gerrit.httpd.LoginUrlToken;
import com.google.gerrit.httpd.WebSession;
import com.google.gerrit.reviewdb.client.Account;
import com.google.gerrit.server.account.AccountException;
import com.google.gerrit.server.account.AccountManager;
import com.google.gerrit.server.account.AuthResult;
import com.google.gwtorm.server.OrmException;
import com.google.inject.Inject;
import com.google.inject.servlet.SessionScoped;
import org.apache.commons.codec.binary.Base64;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import java.io.IOException;
import java.security.NoSuchAlgorithmException;
import java.security.SecureRandom;
import javax.servlet.ServletRequest;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/** OAuth protocol implementation */
@SessionScoped
class OAuthSessionOverOpenID {
static final String GERRIT_LOGIN = "/login";
private static final Logger log = LoggerFactory.getLogger(
OAuthSessionOverOpenID.class);
private static final SecureRandom randomState = newRandomGenerator();
private final String state;
private final DynamicItem<WebSession> webSession;
private final AccountManager accountManager;
private final CanonicalWebUrl urlProvider;
private OAuthServiceProvider serviceProvider;
private OAuthToken token;
private OAuthUserInfo user;
private String redirectToken;
@Inject
OAuthSessionOverOpenID(DynamicItem<WebSession> webSession,
AccountManager accountManager,
CanonicalWebUrl urlProvider) {
this.state = generateRandomState();
this.webSession = webSession;
this.accountManager = accountManager;
this.urlProvider = urlProvider;
}
boolean isLoggedIn() {
return token != null && user != null;
}
boolean isOAuthFinal(HttpServletRequest request) {
return Strings.emptyToNull(request.getParameter("code")) != null;
}
boolean login(HttpServletRequest request, HttpServletResponse response,
OAuthServiceProvider oauth) throws IOException {
if (isLoggedIn()) {
return true;
}
log.debug("Login " + this);
if (isOAuthFinal(request)) {
if (!checkState(request)) {
response.sendError(HttpServletResponse.SC_NOT_FOUND);
return false;
}
log.debug("Login-Retrieve-User " + this);
token = oauth.getAccessToken(new OAuthVerifier(request.getParameter("code")));
user = oauth.getUserInfo(token);
if (isLoggedIn()) {
log.debug("Login-SUCCESS " + this);
authenticateAndRedirect(request, response);
return true;
} else {
response.sendError(SC_UNAUTHORIZED);
return false;
}
} else {
log.debug("Login-PHASE1 " + this);
redirectToken = LoginUrlToken.getToken(request);
response.sendRedirect(oauth.getAuthorizationUrl() +
"&state=" + state);
return false;
}
}
private void authenticateAndRedirect(HttpServletRequest req,
HttpServletResponse rsp) throws IOException {
com.google.gerrit.server.account.AuthRequest areq =
new com.google.gerrit.server.account.AuthRequest(user.getExternalId());
AuthResult arsp = null;
try {
String claimedIdentifier = user.getClaimedIdentity();
Account.Id actualId = accountManager.lookup(user.getExternalId());
if (!Strings.isNullOrEmpty(claimedIdentifier)) {
Account.Id claimedId = accountManager.lookup(claimedIdentifier);
if (claimedId != null && actualId != null) {
if (claimedId.equals(actualId)) {
// Both link to the same account, that's what we expected.
} else {
// This is (for now) a fatal error. There are two records
// for what might be the same user.
//
log.error("OAuth accounts disagree over user identity:\n"
+ " Claimed ID: " + claimedId + " is " + claimedIdentifier
+ "\n" + " Delgate ID: " + actualId + " is "
+ user.getExternalId());
rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
} else if (claimedId != null && actualId == null) {
// Claimed account already exists: link to it.
//
try {
accountManager.link(claimedId, areq);
} catch (OrmException e) {
log.error("Cannot link: " + user.getExternalId()
+ " to user identity:\n"
+ " Claimed ID: " + claimedId + " is " + claimedIdentifier);
rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
}
}
areq.setUserName(user.getUserName());
areq.setEmailAddress(user.getEmailAddress());
areq.setDisplayName(user.getDisplayName());
arsp = accountManager.authenticate(areq);
} catch (AccountException e) {
log.error("Unable to authenticate user \"" + user + "\"", e);
rsp.sendError(HttpServletResponse.SC_FORBIDDEN);
return;
}
webSession.get().login(arsp, true);
StringBuilder rdr = new StringBuilder(urlProvider.get(req));
rdr.append(Url.decode(redirectToken));
rsp.sendRedirect(rdr.toString());
}
void logout() {
token = null;
user = null;
redirectToken = null;
serviceProvider = null;
}
private boolean checkState(ServletRequest request) {
String s = Strings.nullToEmpty(request.getParameter("state"));
if (!s.equals(state)) {
log.error("Illegal request state '" + s + "' on OAuthProtocol " + this);
return false;
}
return true;
}
private static SecureRandom newRandomGenerator() {
try {
return SecureRandom.getInstance("SHA1PRNG");
} catch (NoSuchAlgorithmException e) {
throw new IllegalArgumentException(
"No SecureRandom available for GitHub authentication", e);
}
}
private static String generateRandomState() {
byte[] state = new byte[32];
randomState.nextBytes(state);
return Base64.encodeBase64URLSafeString(state);
}
@Override
public String toString() {
return "OAuthSession [token=" + token + ", user=" + user + "]";
}
public void setServiceProvider(OAuthServiceProvider provider) {
this.serviceProvider = provider;
}
public OAuthServiceProvider getServiceProvider() {
return serviceProvider;
}
}

View File

@ -0,0 +1,116 @@
// 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.auth.openid;
import com.google.common.collect.Iterables;
import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.gerrit.server.CurrentUser;
import com.google.inject.Inject;
import com.google.inject.Provider;
import com.google.inject.Singleton;
import java.io.IOException;
import java.util.SortedMap;
import java.util.SortedSet;
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.HttpSession;
/** OAuth web filter uses active OAuth session to perform OAuth requests */
@Singleton
class OAuthWebFilterOverOpenID implements Filter {
static final String GERRIT_LOGIN = "/login";
private final Provider<CurrentUser> currentUserProvider;
private final Provider<OAuthSessionOverOpenID> oauthSessionProvider;
private final DynamicMap<OAuthServiceProvider> oauthServiceProviders;
private OAuthServiceProvider ssoProvider;
@Inject
OAuthWebFilterOverOpenID(Provider<CurrentUser> currentUserProvider,
DynamicMap<OAuthServiceProvider> oauthServiceProviders,
Provider<OAuthSessionOverOpenID> oauthSessionProvider) {
this.currentUserProvider = currentUserProvider;
this.oauthServiceProviders = oauthServiceProviders;
this.oauthSessionProvider = oauthSessionProvider;
}
@Override
public void init(FilterConfig filterConfig) throws ServletException {
pickSSOServiceProvider();
}
@Override
public void destroy() {
}
@Override
public void doFilter(ServletRequest request, ServletResponse response,
FilterChain chain) throws IOException, ServletException {
HttpServletRequest httpRequest = (HttpServletRequest) request;
HttpSession httpSession = ((HttpServletRequest) request).getSession(false);
if (currentUserProvider.get().isIdentifiedUser()) {
if (httpSession != null) {
httpSession.invalidate();
}
chain.doFilter(request, response);
return;
}
HttpServletResponse httpResponse = (HttpServletResponse) response;
OAuthSessionOverOpenID oauthSession = oauthSessionProvider.get();
OAuthServiceProvider service = ssoProvider == null
? oauthSession.getServiceProvider()
: ssoProvider;
if ((isGerritLogin(httpRequest)
|| oauthSession.isOAuthFinal(httpRequest))
&& !oauthSession.isLoggedIn()) {
if (service == null) {
throw new IllegalStateException("service is unknown");
}
oauthSession.setServiceProvider(service);
oauthSession.login(httpRequest, httpResponse, service);
} else {
chain.doFilter(httpRequest, response);
}
}
private void pickSSOServiceProvider()
throws ServletException {
SortedSet<String> plugins = oauthServiceProviders.plugins();
if (plugins.size() == 1) {
SortedMap<String, Provider<OAuthServiceProvider>> services =
oauthServiceProviders.byPlugin(Iterables.getOnlyElement(plugins));
if (services.size() == 1) {
ssoProvider = Iterables.getOnlyElement(services.values()).get();
}
}
}
private static boolean isGerritLogin(HttpServletRequest request) {
return request.getRequestURI().indexOf(GERRIT_LOGIN) >= 0;
}
}

View File

@ -14,6 +14,8 @@
package com.google.gerrit.httpd.auth.openid;
import com.google.gerrit.extensions.auth.oauth.OAuthServiceProvider;
import com.google.gerrit.extensions.registration.DynamicMap;
import com.google.inject.servlet.ServletModule;
/** Servlets related to OpenID authentication. */
@ -21,9 +23,12 @@ public class OpenIdModule extends ServletModule {
@Override
protected void configureServlets() {
serve("/login", "/login/*").with(LoginForm.class);
serve("/logout").with(OAuthOverOpenIDLogoutServlet.class);
filter("/oauth").through(OAuthWebFilterOverOpenID.class);
serve("/" + OpenIdServiceImpl.RETURN_URL).with(OpenIdLoginServlet.class);
serve("/" + XrdsServlet.LOCATION).with(XrdsServlet.class);
filter("/").through(XrdsFilter.class);
bind(OpenIdServiceImpl.class);
DynamicMap.mapOf(binder(), OAuthServiceProvider.class);
}
}

File diff suppressed because one or more lines are too long