| // 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.server; |
| |
| import com.google.gerrit.client.Gerrit; |
| import com.google.gerrit.client.SignInDialog; |
| import com.google.gerrit.client.SignInDialog.Mode; |
| import com.google.gerrit.client.openid.DiscoveryResult; |
| import com.google.gerrit.client.openid.OpenIdService; |
| import com.google.gerrit.client.openid.OpenIdUtil; |
| import com.google.gerrit.client.reviewdb.Account; |
| import com.google.gerrit.client.reviewdb.AccountExternalId; |
| import com.google.gerrit.client.reviewdb.AccountExternalIdAccess; |
| import com.google.gerrit.client.reviewdb.ReviewDb; |
| import com.google.gerrit.client.reviewdb.SystemConfig; |
| import com.google.gerrit.client.rpc.Common; |
| import com.google.gwt.user.client.rpc.AsyncCallback; |
| import com.google.gwtjsonrpc.server.ValidToken; |
| import com.google.gwtjsonrpc.server.XsrfException; |
| import com.google.gwtorm.client.OrmException; |
| import com.google.gwtorm.client.ResultSet; |
| import com.google.gwtorm.client.Transaction; |
| |
| import net.sf.ehcache.Element; |
| import net.sf.ehcache.constructs.blocking.CacheEntryFactory; |
| import net.sf.ehcache.constructs.blocking.SelfPopulatingCache; |
| |
| 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.discovery.Identifier; |
| 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.sreg.SRegMessage; |
| import org.openid4java.message.sreg.SRegRequest; |
| import org.openid4java.message.sreg.SRegResponse; |
| import org.slf4j.Logger; |
| import org.slf4j.LoggerFactory; |
| |
| import java.io.IOException; |
| import java.util.ArrayList; |
| import java.util.Collections; |
| import java.util.List; |
| import java.util.TreeMap; |
| |
| import javax.servlet.http.Cookie; |
| import javax.servlet.http.HttpServletRequest; |
| import javax.servlet.http.HttpServletResponse; |
| |
| class OpenIdServiceImpl implements OpenIdService { |
| private static final Logger log = |
| LoggerFactory.getLogger(OpenIdServiceImpl.class); |
| |
| 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 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 static OpenIdServiceImpl INSTANCE; |
| |
| private static boolean useOpenID() { |
| return Common.getGerritConfig().getLoginType() == SystemConfig.LoginType.OPENID; |
| } |
| |
| static synchronized OpenIdServiceImpl getInstance() throws ConsumerException, |
| OrmException, XsrfException { |
| if (INSTANCE == null) { |
| INSTANCE = new OpenIdServiceImpl(); |
| } |
| return INSTANCE; |
| } |
| |
| private final GerritServer server; |
| private final ConsumerManager manager; |
| private final SelfPopulatingCache discoveryCache; |
| |
| private OpenIdServiceImpl() throws ConsumerException, OrmException, |
| XsrfException { |
| server = GerritServer.getInstance(); |
| manager = new ConsumerManager(); |
| if (useOpenID()) { |
| discoveryCache = |
| new SelfPopulatingCache(server.getCache("openid"), |
| new CacheEntryFactory() { |
| public Object createEntry(final Object objKey) throws Exception { |
| try { |
| final List<?> list = manager.discover((String) objKey); |
| return list != null && !list.isEmpty() ? list : null; |
| } catch (DiscoveryException e) { |
| return null; |
| } |
| } |
| }); |
| } else { |
| discoveryCache = null; |
| } |
| } |
| |
| public void discover(final String openidIdentifier, |
| final SignInDialog.Mode mode, final boolean remember, |
| final String returnToken, final AsyncCallback<DiscoveryResult> callback) { |
| if (!useOpenID()) { |
| callback.onFailure(new IllegalStateException("OpenID not enabled")); |
| return; |
| } |
| |
| final HttpServletRequest httpReq = |
| GerritJsonServlet.getCurrentCall().getHttpServletRequest(); |
| final State state; |
| state = init(httpReq, openidIdentifier, mode, remember, returnToken); |
| if (state == null) { |
| callback.onSuccess(new DiscoveryResult(false)); |
| 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); |
| } |
| } catch (MessageException e) { |
| callback.onSuccess(new DiscoveryResult(false)); |
| return; |
| } catch (ConsumerException e) { |
| callback.onSuccess(new DiscoveryResult(false)); |
| return; |
| } |
| |
| callback.onSuccess(new DiscoveryResult(true, 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 { |
| final ReviewDb db = Common.getSchemaFactory().open(); |
| try { |
| final ResultSet<AccountExternalId> ae = |
| db.accountExternalIds().byExternal(aReq.getIdentity()); |
| if (ae.iterator().hasNext()) { |
| // We already have it. Don't bother asking for the |
| // registration information, we have what we need. |
| // |
| return false; |
| } |
| } finally { |
| db.close(); |
| } |
| } catch (OrmException e) { |
| log.warn("Failed looking for existing account", e); |
| } |
| |
| // We don't have this account on file, or our query failed. Assume |
| // we should ask for registration information in case the account |
| // turns out to be new. |
| // |
| return true; |
| } |
| |
| /** Called by {@link OpenIdLoginServlet} doGet, doPost */ |
| void doAuth(final HttpServletRequest req, final HttpServletResponse rsp) |
| throws Exception { |
| if (false) { |
| debugRequest(req); |
| } |
| |
| final String openidMode = req.getParameter(OPENID_MODE); |
| if (OMODE_CANCEL.equals(openidMode)) { |
| cancel(req, rsp); |
| |
| } else { |
| // Process the authentication response. |
| // |
| final SignInDialog.Mode mode = signInMode(req); |
| final String openidIdentifier = req.getParameter("openid.identity"); |
| final String returnToken = req.getParameter(P_TOKEN); |
| final boolean remember = "1".equals(req.getParameter(P_REMEMBER)); |
| final State state; |
| |
| state = init(req, openidIdentifier, 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); |
| final Identifier user = result.getVerifiedId(); |
| |
| if (user == null) { |
| // Authentication failed. |
| // |
| cancel(req, rsp); |
| |
| } else { |
| // Authentication was successful. |
| // |
| final Message authRsp = result.getAuthResponse(); |
| SRegResponse sregRsp = null; |
| FetchResponse fetchRsp = null; |
| |
| 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; |
| } |
| } |
| |
| String fullname = null; |
| String email = null; |
| |
| if (sregRsp != null) { |
| fullname = sregRsp.getAttributeValue("fullname"); |
| email = 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); |
| } |
| fullname = n.length() > 0 ? n.toString() : null; |
| email = fetchRsp.getAttributeValue("Email"); |
| } |
| |
| initializeAccount(req, rsp, user, fullname, email); |
| } |
| } |
| } |
| |
| @SuppressWarnings("unchecked") |
| private static void debugRequest(final HttpServletRequest req) { |
| System.err.println(req.getMethod() + " /login"); |
| for (final String n : new TreeMap<String, Object>(req.getParameterMap()) |
| .keySet()) { |
| for (final String v : req.getParameterValues(n)) { |
| System.err.println(" " + n + "=" + v); |
| } |
| } |
| System.err.println(); |
| } |
| |
| private void initializeAccount(final HttpServletRequest req, |
| final HttpServletResponse rsp, final Identifier user, |
| final String fullname, final String email) throws IOException { |
| final SignInDialog.Mode mode = signInMode(req); |
| Account account = null; |
| if (user != null) { |
| try { |
| final ReviewDb d = Common.getSchemaFactory().open(); |
| try { |
| switch (mode) { |
| case SIGN_IN: |
| account = openAccount(d, user, fullname, email); |
| break; |
| case LINK_IDENTIY: |
| account = linkAccount(req, d, user, email); |
| break; |
| } |
| } finally { |
| d.close(); |
| } |
| } catch (OrmException e) { |
| log.error("Account lookup failed", e); |
| account = null; |
| } |
| } |
| |
| Cookie c = new Cookie(Gerrit.ACCOUNT_COOKIE, ""); |
| c.setPath(req.getContextPath() + "/"); |
| |
| if (account == null) { |
| if (mode == SignInDialog.Mode.SIGN_IN) { |
| c.setMaxAge(0); |
| rsp.addCookie(c); |
| } |
| cancel(req, rsp); |
| |
| } else if (mode == SignInDialog.Mode.SIGN_IN) { |
| final boolean remember = "1".equals(req.getParameter(P_REMEMBER)); |
| |
| new AccountCookie(account.getId(), remember).set(c, server); |
| rsp.addCookie(c); |
| |
| c = new Cookie(OpenIdUtil.LASTID_COOKIE, ""); |
| c.setPath(req.getContextPath() + "/"); |
| if (remember) { |
| c.setValue(user.getIdentifier()); |
| c.setMaxAge(LASTID_AGE); |
| } else { |
| c.setMaxAge(0); |
| } |
| rsp.addCookie(c); |
| |
| callback(req, rsp); |
| |
| } else { |
| callback(req, rsp); |
| } |
| } |
| |
| private Account openAccount(final ReviewDb db, final Identifier user, |
| final String fullname, final String email) throws OrmException { |
| Account account; |
| final AccountExternalIdAccess extAccess = db.accountExternalIds(); |
| AccountExternalId acctExt = lookup(extAccess, user.getIdentifier()); |
| |
| if (acctExt == null && email != null |
| && server.isAllowGoogleAccountUpgrade() && isGoogleAccount(user)) { |
| acctExt = lookupGoogleAccount(extAccess, email); |
| if (acctExt != null) { |
| // Legacy user from Gerrit 1? Attach the OpenID identity. |
| // |
| final AccountExternalId openidExt = |
| new AccountExternalId(new AccountExternalId.Key(acctExt |
| .getAccountId(), user.getIdentifier())); |
| extAccess.insert(Collections.singleton(openidExt)); |
| acctExt = openidExt; |
| } |
| } |
| |
| if (acctExt != null) { |
| // Existing user; double check the email is current. |
| // |
| if (email != null && !email.equals(acctExt.getEmailAddress())) { |
| acctExt.setEmailAddress(email); |
| } |
| acctExt.setLastUsedOn(); |
| extAccess.update(Collections.singleton(acctExt)); |
| account = Common.getAccountCache().get(acctExt.getAccountId(), db); |
| } else { |
| account = null; |
| } |
| |
| if (account == null) { |
| // New user; create an account entity for them. |
| // |
| final Transaction txn = db.beginTransaction(); |
| |
| account = new Account(new Account.Id(db.nextAccountId())); |
| account.setFullName(fullname); |
| account.setPreferredEmail(email); |
| |
| acctExt = |
| new AccountExternalId(new AccountExternalId.Key(account.getId(), user |
| .getIdentifier())); |
| acctExt.setLastUsedOn(); |
| acctExt.setEmailAddress(email); |
| |
| db.accounts().insert(Collections.singleton(account), txn); |
| extAccess.insert(Collections.singleton(acctExt), txn); |
| txn.commit(); |
| } |
| return account; |
| } |
| |
| private Account linkAccount(final HttpServletRequest req, final ReviewDb db, |
| final Identifier user, final String email) throws OrmException { |
| final Cookie[] cookies = req.getCookies(); |
| if (cookies == null) { |
| return null; |
| } |
| Account.Id me = null; |
| for (final Cookie c : cookies) { |
| if (Gerrit.ACCOUNT_COOKIE.equals(c.getName())) { |
| try { |
| final ValidToken tok = |
| server.getAccountToken().checkToken(c.getValue(), null); |
| if (tok == null) { |
| return null; |
| } |
| me = AccountCookie.parse(tok).getAccountId(); |
| break; |
| } catch (XsrfException e) { |
| return null; |
| } catch (RuntimeException e) { |
| return null; |
| } |
| } |
| } |
| if (me == null) { |
| return null; |
| } |
| |
| final Account account = Common.getAccountCache().get(me, db); |
| if (account == null) { |
| return null; |
| } |
| |
| final AccountExternalId.Key idKey = |
| new AccountExternalId.Key(account.getId(), user.getIdentifier()); |
| AccountExternalId id = db.accountExternalIds().get(idKey); |
| if (id == null) { |
| id = new AccountExternalId(idKey); |
| id.setLastUsedOn(); |
| id.setEmailAddress(email); |
| db.accountExternalIds().insert(Collections.singleton(id)); |
| Common.getGroupCache().invalidate(account.getId()); |
| } else { |
| if (email != null && !email.equals(id.getEmailAddress())) { |
| id.setEmailAddress(email); |
| } |
| id.setLastUsedOn(); |
| db.accountExternalIds().update(Collections.singleton(id)); |
| } |
| return account; |
| } |
| |
| private static Mode signInMode(final HttpServletRequest req) { |
| try { |
| return SignInDialog.Mode.valueOf(req.getParameter(P_MODE)); |
| } catch (RuntimeException e) { |
| return SignInDialog.Mode.SIGN_IN; |
| } |
| } |
| |
| private static AccountExternalId lookup( |
| final AccountExternalIdAccess extAccess, final String id) |
| throws OrmException { |
| final List<AccountExternalId> extRes = extAccess.byExternal(id).toList(); |
| switch (extRes.size()) { |
| case 0: |
| return null; |
| case 1: |
| return extRes.get(0); |
| default: |
| throw new OrmException("More than one account matches: " + id); |
| } |
| } |
| |
| private static boolean isGoogleAccount(final Identifier user) { |
| return user.getIdentifier().startsWith(OpenIdUtil.URL_GOOGLE + "?"); |
| } |
| |
| private static AccountExternalId lookupGoogleAccount( |
| final AccountExternalIdAccess extAccess, final String email) |
| throws OrmException { |
| // We may have multiple records which match the email address, but |
| // all under the same account. This happens when the user does a |
| // login through different server hostnames, as Google issues |
| // unique OpenID tokens per server. |
| // |
| // Match to an existing account only if there is exactly one record |
| // for this email using the generic Google identity. |
| // |
| final List<AccountExternalId> m = new ArrayList<AccountExternalId>(); |
| for (final AccountExternalId e : extAccess.byEmailAddress(email)) { |
| if (e.getExternalId().equals("Google Account " + email)) { |
| m.add(e); |
| } |
| } |
| return m.size() == 1 ? m.get(0) : null; |
| } |
| |
| private static void callback(final HttpServletRequest req, |
| final HttpServletResponse rsp) throws IOException { |
| final StringBuilder rdr = new StringBuilder(); |
| rdr.append(GerritServer.serverUrl(req)); |
| rdr.append("Gerrit"); |
| final String token = req.getParameter(P_TOKEN); |
| if (token != null) { |
| rdr.append('#'); |
| rdr.append(token); |
| } |
| rsp.sendRedirect(rdr.toString()); |
| } |
| |
| private static void cancel(final HttpServletRequest req, |
| final HttpServletResponse rsp) throws IOException { |
| callback(req, rsp); |
| } |
| |
| private State init(final HttpServletRequest httpReq, |
| final String openidIdentifier, final SignInDialog.Mode mode, |
| final boolean remember, final String returnToken) { |
| final Element serverCache = discoveryCache.get(openidIdentifier); |
| if (serverCache == null) { |
| return null; |
| } |
| |
| final List<?> list = (List<?>) serverCache.getObjectValue(); |
| if (list == null || list.isEmpty()) { |
| return null; |
| } |
| |
| final String contextUrl = GerritServer.serverUrl(httpReq); |
| final DiscoveryInformation discovered = manager.associate(list); |
| final UrlEncoded retTo = new UrlEncoded(contextUrl + "login"); |
| retTo.put(P_MODE, mode.name()); |
| if (returnToken != null && returnToken.length() > 0) { |
| retTo.put(P_TOKEN, returnToken); |
| } |
| if (remember) { |
| retTo.put(P_REMEMBER, "1"); |
| } |
| return new State(discovered, retTo, contextUrl); |
| } |
| |
| 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; |
| } |
| } |
| } |