| // Copyright (C) 2009 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.common.PageLinks; |
| import com.google.gerrit.common.auth.SignInMode; |
| import com.google.gerrit.common.auth.openid.DiscoveryResult; |
| import com.google.gerrit.common.auth.openid.OpenIdProviderPattern; |
| import com.google.gerrit.common.auth.openid.OpenIdService; |
| import com.google.gerrit.common.auth.openid.OpenIdUrls; |
| import com.google.gerrit.httpd.WebSession; |
| import com.google.gerrit.reviewdb.client.Account; |
| import com.google.gerrit.server.IdentifiedUser; |
| import com.google.gerrit.server.UrlEncoded; |
| import com.google.gerrit.server.account.AccountException; |
| import com.google.gerrit.server.account.AccountManager; |
| import com.google.gerrit.server.config.AuthConfig; |
| import com.google.gerrit.server.config.CanonicalWebUrl; |
| import com.google.gerrit.server.config.ConfigUtil; |
| import com.google.gerrit.server.config.GerritServerConfig; |
| import com.google.gwtjsonrpc.common.AsyncCallback; |
| import com.google.gwtorm.client.KeyUtil; |
| import com.google.inject.Inject; |
| import com.google.inject.Provider; |
| import com.google.inject.Singleton; |
| |
| import org.eclipse.jgit.lib.Config; |
| import org.openid4java.consumer.ConsumerException; |
| import org.openid4java.consumer.ConsumerManager; |
| import org.openid4java.consumer.VerificationResult; |
| import org.openid4java.discovery.DiscoveryException; |
| import org.openid4java.discovery.DiscoveryInformation; |
| import org.openid4java.message.AuthRequest; |
| import org.openid4java.message.Message; |
| import org.openid4java.message.MessageException; |
| import org.openid4java.message.MessageExtension; |
| import org.openid4java.message.ParameterList; |
| import org.openid4java.message.ax.AxMessage; |
| import org.openid4java.message.ax.FetchRequest; |
| import org.openid4java.message.ax.FetchResponse; |
| import org.openid4java.message.pape.PapeMessage; |
| import org.openid4java.message.pape.PapeRequest; |
| import org.openid4java.message.pape.PapeResponse; |
| import org.openid4java.message.sreg.SRegMessage; |
| import org.openid4java.message.sreg.SRegRequest; |
| import org.openid4java.message.sreg.SRegResponse; |
| import org.openid4java.util.HttpClientFactory; |
| import org.openid4java.util.ProxyProperties; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import java.io.IOException; |
| import java.net.MalformedURLException; |
| import java.net.URL; |
| import java.util.List; |
| import java.util.concurrent.TimeUnit; |
| |
| import javax.annotation.Nullable; |
| import javax.servlet.http.Cookie; |
| import javax.servlet.http.HttpServletRequest; |
| import javax.servlet.http.HttpServletResponse; |
| |
| @Singleton |
| class OpenIdServiceImpl implements OpenIdService { |
| private static final Logger log = |
| LoggerFactory.getLogger(OpenIdServiceImpl.class); |
| |
| static final String RETURN_URL = "OpenID"; |
| |
| private static final String P_MODE = "gerrit.mode"; |
| private static final String P_TOKEN = "gerrit.token"; |
| private static final String P_REMEMBER = "gerrit.remember"; |
| private static final String P_CLAIMED = "gerrit.claimed"; |
| private static final int LASTID_AGE = 365 * 24 * 60 * 60; // seconds |
| |
| private static final String OPENID_MODE = "openid.mode"; |
| private static final String OMODE_CANCEL = "cancel"; |
| |
| private static final String SCHEMA_EMAIL = |
| "http://schema.openid.net/contact/email"; |
| private static final String SCHEMA_FIRSTNAME = |
| "http://schema.openid.net/namePerson/first"; |
| private static final String SCHEMA_LASTNAME = |
| "http://schema.openid.net/namePerson/last"; |
| |
| private final Provider<WebSession> webSession; |
| private final Provider<IdentifiedUser> identifiedUser; |
| private final Provider<String> urlProvider; |
| private final AccountManager accountManager; |
| private final ConsumerManager manager; |
| private final List<OpenIdProviderPattern> allowedOpenIDs; |
| |
| /** Maximum age, in seconds, before forcing re-authentication of account. */ |
| private final int papeMaxAuthAge; |
| |
| @Inject |
| OpenIdServiceImpl(final Provider<WebSession> cf, |
| final Provider<IdentifiedUser> iu, |
| @CanonicalWebUrl @Nullable final Provider<String> up, |
| @GerritServerConfig final Config config, final AuthConfig ac, |
| final AccountManager am) throws ConsumerException, MalformedURLException { |
| |
| if (config.getString("http", null, "proxy") != null) { |
| final URL proxyUrl = new URL(config.getString("http", null, "proxy")); |
| String username = config.getString("http", null, "proxyUsername"); |
| String password = config.getString("http", null, "proxyPassword"); |
| |
| final String userInfo = proxyUrl.getUserInfo(); |
| if (userInfo != null) { |
| int c = userInfo.indexOf(':'); |
| if (0 < c) { |
| username = userInfo.substring(0, c); |
| password = userInfo.substring(c + 1); |
| } else { |
| username = userInfo; |
| } |
| } |
| |
| final ProxyProperties proxy = new ProxyProperties(); |
| proxy.setProxyHostName(proxyUrl.getHost()); |
| proxy.setProxyPort(proxyUrl.getPort()); |
| proxy.setUserName(username); |
| proxy.setPassword(password); |
| HttpClientFactory.setProxyProperties(proxy); |
| } |
| |
| webSession = cf; |
| identifiedUser = iu; |
| urlProvider = up; |
| accountManager = am; |
| manager = new ConsumerManager(); |
| allowedOpenIDs = ac.getAllowedOpenIDs(); |
| papeMaxAuthAge = (int) ConfigUtil.getTimeUnit(config, // |
| "auth", null, "maxOpenIdSessionAge", -1, TimeUnit.SECONDS); |
| } |
| |
| @SuppressWarnings("unchecked") |
| public void discover(final String openidIdentifier, final SignInMode mode, |
| final boolean remember, final String returnToken, |
| final AsyncCallback<DiscoveryResult> cb) { |
| if (!isAllowedOpenID(openidIdentifier)) { |
| cb.onSuccess(new DiscoveryResult(DiscoveryResult.Status.NOT_ALLOWED)); |
| return; |
| } |
| |
| final State state; |
| state = init(openidIdentifier, mode, remember, returnToken); |
| if (state == null) { |
| cb.onSuccess(new DiscoveryResult(DiscoveryResult.Status.NO_PROVIDER)); |
| return; |
| } |
| |
| final AuthRequest aReq; |
| try { |
| aReq = manager.authenticate(state.discovered, state.retTo.toString()); |
| aReq.setRealm(state.contextUrl); |
| |
| if (requestRegistration(aReq)) { |
| final SRegRequest sregReq = SRegRequest.createFetchRequest(); |
| sregReq.addAttribute("fullname", true); |
| sregReq.addAttribute("email", true); |
| aReq.addExtension(sregReq); |
| |
| final FetchRequest fetch = FetchRequest.createFetchRequest(); |
| fetch.addAttribute("FirstName", SCHEMA_FIRSTNAME, true); |
| fetch.addAttribute("LastName", SCHEMA_LASTNAME, true); |
| fetch.addAttribute("Email", SCHEMA_EMAIL, true); |
| aReq.addExtension(fetch); |
| } |
| |
| if (0 <= papeMaxAuthAge) { |
| final PapeRequest pape = PapeRequest.createPapeRequest(); |
| pape.setMaxAuthAge(papeMaxAuthAge); |
| aReq.addExtension(pape); |
| } |
| } catch (MessageException e) { |
| log.error("Cannot create OpenID redirect for " + openidIdentifier, e); |
| cb.onSuccess(new DiscoveryResult(DiscoveryResult.Status.ERROR)); |
| return; |
| } catch (ConsumerException e) { |
| log.error("Cannot create OpenID redirect for " + openidIdentifier, e); |
| cb.onSuccess(new DiscoveryResult(DiscoveryResult.Status.ERROR)); |
| return; |
| } |
| |
| cb.onSuccess(new DiscoveryResult(aReq.getDestinationUrl(false), // |
| aReq.getParameterMap())); |
| } |
| |
| private boolean requestRegistration(final AuthRequest aReq) { |
| if (AuthRequest.SELECT_ID.equals(aReq.getIdentity())) { |
| // We don't know anything about the identity, as the provider |
| // will offer the user a way to indicate their identity. Skip |
| // any database query operation and assume we must ask for the |
| // registration information, in case the identity is new to us. |
| // |
| return true; |
| |
| } |
| |
| // We might already have this account on file. Look for it. |
| // |
| try { |
| return accountManager.lookup(aReq.getIdentity()) == null; |
| } catch (AccountException e) { |
| log.warn("Cannot determine if user account exists", e); |
| return true; |
| } |
| } |
| |
| /** Called by {@link OpenIdLoginServlet} doGet, doPost */ |
| void doAuth(final HttpServletRequest req, final HttpServletResponse rsp) |
| throws Exception { |
| if (OMODE_CANCEL.equals(req.getParameter(OPENID_MODE))) { |
| cancel(req, rsp); |
| return; |
| } |
| |
| // Process the authentication response. |
| // |
| final SignInMode mode = signInMode(req); |
| final String openidIdentifier = req.getParameter("openid.identity"); |
| final String claimedIdentifier = req.getParameter(P_CLAIMED); |
| final String returnToken = req.getParameter(P_TOKEN); |
| final boolean remember = "1".equals(req.getParameter(P_REMEMBER)); |
| final String rediscoverIdentifier = |
| claimedIdentifier != null ? claimedIdentifier : openidIdentifier; |
| final State state; |
| |
| if (!isAllowedOpenID(rediscoverIdentifier) |
| || !isAllowedOpenID(openidIdentifier) |
| || (claimedIdentifier != null && !isAllowedOpenID(claimedIdentifier))) { |
| cancelWithError(req, rsp, "Provider not allowed"); |
| return; |
| } |
| |
| state = init(rediscoverIdentifier, mode, remember, returnToken); |
| if (state == null) { |
| // Re-discovery must have failed, we can't run a login. |
| // |
| cancel(req, rsp); |
| return; |
| } |
| |
| final String returnTo = req.getParameter("openid.return_to"); |
| if (returnTo != null && returnTo.contains("openid.rpnonce=")) { |
| // Some providers (claimid.com) seem to embed these request |
| // parameters into our return_to URL, and then give us them |
| // in the return_to request parameter. But not all. |
| // |
| state.retTo.put("openid.rpnonce", req.getParameter("openid.rpnonce")); |
| state.retTo.put("openid.rpsig", req.getParameter("openid.rpsig")); |
| } |
| |
| final VerificationResult result = |
| manager.verify(state.retTo.toString(), new ParameterList(req |
| .getParameterMap()), state.discovered); |
| if (result.getVerifiedId() == null /* authentication failure */) { |
| if ("Nonce verification failed.".equals(result.getStatusMsg())) { |
| // We might be suffering from clock skew on this system. |
| // |
| log.error("OpenID failure: " + result.getStatusMsg() |
| + " Likely caused by clock skew on this server," |
| + " install/configure NTP."); |
| cancelWithError(req, rsp, result.getStatusMsg()); |
| |
| } else if (result.getStatusMsg() != null) { |
| // Authentication failed. |
| // |
| log.error("OpenID failure: " + result.getStatusMsg()); |
| cancelWithError(req, rsp, result.getStatusMsg()); |
| |
| } else { |
| // Assume authentication was canceled. |
| // |
| cancel(req, rsp); |
| } |
| return; |
| } |
| |
| final Message authRsp = result.getAuthResponse(); |
| SRegResponse sregRsp = null; |
| FetchResponse fetchRsp = null; |
| |
| if (0 <= papeMaxAuthAge) { |
| PapeResponse ext; |
| boolean unsupported = false; |
| |
| try { |
| ext = (PapeResponse) authRsp.getExtension(PapeMessage.OPENID_NS_PAPE); |
| } catch (MessageException err) { |
| // Far too many providers are unable to provide PAPE extensions |
| // right now. Instead of blocking all of them log the error and |
| // let the authentication complete anyway. |
| // |
| log.error("Invalid PAPE response " + openidIdentifier + ": " + err); |
| unsupported = true; |
| ext = null; |
| } |
| if (!unsupported && ext == null) { |
| log.error("No PAPE extension response from " + openidIdentifier); |
| cancelWithError(req, rsp, "OpenID provider does not support PAPE."); |
| return; |
| } |
| } |
| |
| if (authRsp.hasExtension(SRegMessage.OPENID_NS_SREG)) { |
| final MessageExtension ext = |
| authRsp.getExtension(SRegMessage.OPENID_NS_SREG); |
| if (ext instanceof SRegResponse) { |
| sregRsp = (SRegResponse) ext; |
| } |
| } |
| |
| if (authRsp.hasExtension(AxMessage.OPENID_NS_AX)) { |
| final MessageExtension ext = authRsp.getExtension(AxMessage.OPENID_NS_AX); |
| if (ext instanceof FetchResponse) { |
| fetchRsp = (FetchResponse) ext; |
| } |
| } |
| |
| final com.google.gerrit.server.account.AuthRequest areq = |
| new com.google.gerrit.server.account.AuthRequest(openidIdentifier); |
| |
| if (sregRsp != null) { |
| areq.setDisplayName(sregRsp.getAttributeValue("fullname")); |
| areq.setEmailAddress(sregRsp.getAttributeValue("email")); |
| |
| } else if (fetchRsp != null) { |
| final String firstName = fetchRsp.getAttributeValue("FirstName"); |
| final String lastName = fetchRsp.getAttributeValue("LastName"); |
| final StringBuilder n = new StringBuilder(); |
| if (firstName != null && firstName.length() > 0) { |
| n.append(firstName); |
| } |
| if (lastName != null && lastName.length() > 0) { |
| if (n.length() > 0) { |
| n.append(' '); |
| } |
| n.append(lastName); |
| } |
| areq.setDisplayName(n.length() > 0 ? n.toString() : null); |
| areq.setEmailAddress(fetchRsp.getAttributeValue("Email")); |
| } |
| |
| if (claimedIdentifier != null) { |
| // The user used a claimed identity which has delegated to the verified |
| // identity we have in our AuthRequest above. We still should have a |
| // link between the two, so set one up if not present. |
| // |
| Account.Id claimedId = accountManager.lookup(claimedIdentifier); |
| Account.Id actualId = accountManager.lookup(areq.getExternalId()); |
| |
| 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("OpenID accounts disagree over user identity:\n" |
| + " Claimed ID: " + claimedId + " is " + claimedIdentifier |
| + "\n" + " Delgate ID: " + actualId + " is " |
| + areq.getExternalId()); |
| cancelWithError(req, rsp, "Contact site administrator"); |
| return; |
| } |
| |
| } else if (claimedId == null && actualId != null) { |
| // Older account, the actual was already created but the claimed |
| // was missing due to a bug in Gerrit. Link the claimed. |
| // |
| final com.google.gerrit.server.account.AuthRequest linkReq = |
| new com.google.gerrit.server.account.AuthRequest(claimedIdentifier); |
| linkReq.setDisplayName(areq.getDisplayName()); |
| linkReq.setEmailAddress(areq.getEmailAddress()); |
| accountManager.link(actualId, linkReq); |
| |
| } else if (claimedId != null && actualId == null) { |
| // Claimed account already exists, but it smells like the user has |
| // changed their delegate to point to a different provider. Link |
| // the new provider. |
| // |
| accountManager.link(claimedId, areq); |
| |
| } else { |
| // Both are null, we are going to create a new account below. |
| } |
| } |
| |
| try { |
| final com.google.gerrit.server.account.AuthResult arsp; |
| switch (mode) { |
| case REGISTER: |
| case SIGN_IN: |
| arsp = accountManager.authenticate(areq); |
| |
| final Cookie lastId = new Cookie(OpenIdUrls.LASTID_COOKIE, ""); |
| lastId.setPath(req.getContextPath() + "/"); |
| if (remember) { |
| lastId.setValue(rediscoverIdentifier); |
| lastId.setMaxAge(LASTID_AGE); |
| } else { |
| lastId.setMaxAge(0); |
| } |
| rsp.addCookie(lastId); |
| webSession.get().login(arsp, remember); |
| if (arsp.isNew() && claimedIdentifier != null) { |
| final com.google.gerrit.server.account.AuthRequest linkReq = |
| new com.google.gerrit.server.account.AuthRequest( |
| claimedIdentifier); |
| linkReq.setDisplayName(areq.getDisplayName()); |
| linkReq.setEmailAddress(areq.getEmailAddress()); |
| accountManager.link(arsp.getAccountId(), linkReq); |
| } |
| callback(arsp.isNew(), req, rsp); |
| break; |
| |
| case LINK_IDENTIY: { |
| arsp = accountManager.link(identifiedUser.get().getAccountId(), areq); |
| webSession.get().login(arsp, remember); |
| callback(false, req, rsp); |
| break; |
| } |
| } |
| } catch (AccountException e) { |
| log.error("OpenID authentication failure", e); |
| cancelWithError(req, rsp, "Contact site administrator"); |
| } |
| } |
| |
| private boolean isSignIn(final SignInMode mode) { |
| switch (mode) { |
| case SIGN_IN: |
| case REGISTER: |
| return true; |
| default: |
| return false; |
| } |
| } |
| |
| private static SignInMode signInMode(final HttpServletRequest req) { |
| try { |
| return SignInMode.valueOf(req.getParameter(P_MODE)); |
| } catch (RuntimeException e) { |
| return SignInMode.SIGN_IN; |
| } |
| } |
| |
| private void callback(final boolean isNew, final HttpServletRequest req, |
| final HttpServletResponse rsp) throws IOException { |
| String token = req.getParameter(P_TOKEN); |
| if (token == null || token.isEmpty() || token.startsWith("/SignInFailure,")) { |
| token = PageLinks.MINE; |
| } |
| |
| final StringBuilder rdr = new StringBuilder(); |
| rdr.append(urlProvider.get()); |
| rdr.append('#'); |
| if (isNew && !token.startsWith(PageLinks.REGISTER + "/")) { |
| rdr.append(PageLinks.REGISTER); |
| } |
| rdr.append(token); |
| rsp.sendRedirect(rdr.toString()); |
| } |
| |
| private void cancel(final HttpServletRequest req, |
| final HttpServletResponse rsp) throws IOException { |
| if (isSignIn(signInMode(req))) { |
| webSession.get().logout(); |
| } |
| callback(false, req, rsp); |
| } |
| |
| private void cancelWithError(final HttpServletRequest req, |
| final HttpServletResponse rsp, final String errorDetail) |
| throws IOException { |
| final SignInMode mode = signInMode(req); |
| if (isSignIn(mode)) { |
| webSession.get().logout(); |
| } |
| final StringBuilder rdr = new StringBuilder(); |
| rdr.append(urlProvider.get()); |
| rdr.append('#'); |
| rdr.append("SignInFailure"); |
| rdr.append(','); |
| rdr.append(mode.name()); |
| rdr.append(','); |
| rdr.append(errorDetail != null ? KeyUtil.encode(errorDetail) : ""); |
| rsp.sendRedirect(rdr.toString()); |
| } |
| |
| private State init(final String openidIdentifier, final SignInMode mode, |
| final boolean remember, final String returnToken) { |
| final List<?> list; |
| try { |
| list = manager.discover(openidIdentifier); |
| } catch (DiscoveryException e) { |
| log.error("Cannot discover OpenID " + openidIdentifier, e); |
| return null; |
| } |
| if (list == null || list.isEmpty()) { |
| return null; |
| } |
| |
| final String contextUrl = urlProvider.get(); |
| final DiscoveryInformation discovered = manager.associate(list); |
| final UrlEncoded retTo = new UrlEncoded(contextUrl + RETURN_URL); |
| retTo.put(P_MODE, mode.name()); |
| if (returnToken != null && returnToken.length() > 0) { |
| retTo.put(P_TOKEN, returnToken); |
| } |
| if (remember) { |
| retTo.put(P_REMEMBER, "1"); |
| } |
| if (discovered.hasClaimedIdentifier()) { |
| retTo.put(P_CLAIMED, discovered.getClaimedIdentifier().getIdentifier()); |
| } |
| return new State(discovered, retTo, contextUrl); |
| } |
| |
| private boolean isAllowedOpenID(final String id) { |
| for (final OpenIdProviderPattern pattern : allowedOpenIDs) { |
| if (pattern.matches(id)) { |
| return true; |
| } |
| } |
| return false; |
| } |
| |
| private static class State { |
| final DiscoveryInformation discovered; |
| final UrlEncoded retTo; |
| final String contextUrl; |
| |
| State(final DiscoveryInformation d, final UrlEncoded r, final String c) { |
| discovered = d; |
| retTo = r; |
| contextUrl = c; |
| } |
| } |
| } |