blob: e7a38d4590ef64bcf847a6cbe7e1641cddf0feef [file] [log] [blame]
// 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 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;
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 OpenIdServiceImpl() throws ConsumerException, OrmException,
XsrfException {
server = GerritServer.getInstance();
manager = new ConsumerManager();
}
public void discover(final String openidIdentifier,
final SignInDialog.Mode mode, final boolean remember,
final String returnToken, final AsyncCallback<DiscoveryResult> callback) {
if (Common.getGerritConfig().getLoginType() != SystemConfig.LoginType.OPENID) {
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) {
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();
}
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");
fullname = firstName + " " + lastName;
email = fetchRsp.getAttributeValue("Email");
}
initializeAccount(req, rsp, user, fullname, email);
}
}
}
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) {
List<?> servers;
try {
servers = manager.discover(openidIdentifier);
} catch (DiscoveryException de) {
servers = null;
}
if (servers == null || servers.isEmpty()) {
return null;
}
final String contextUrl = GerritServer.serverUrl(httpReq);
final DiscoveryInformation discovered = manager.associate(servers);
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;
}
}
}