blob: 0593bce94f89d2d059367327d0b5895cffaf8113 [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.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;
}
}
}