blob: 07a7314c696a0ea242d585b20848f9a02510bc8e [file] [log] [blame]
// Copyright (C) 2008 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.account.SignInResult;
import com.google.gerrit.client.openid.DiscoveryResult;
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.rpc.Common;
import com.google.gwt.user.server.rpc.RPCServletUtils;
import com.google.gwtjsonrpc.server.JsonServlet;
import com.google.gwtjsonrpc.server.ValidToken;
import com.google.gwtjsonrpc.server.XsrfException;
import com.google.gwtorm.client.OrmException;
import com.google.gwtorm.client.Transaction;
import com.dyuproject.openid.Constants;
import com.dyuproject.openid.DefaultDiscovery;
import com.dyuproject.openid.DiffieHellmanAssociation;
import com.dyuproject.openid.OpenIdContext;
import com.dyuproject.openid.OpenIdUser;
import com.dyuproject.openid.RelyingParty;
import com.dyuproject.openid.SimpleHttpConnector;
import com.dyuproject.openid.UrlEncodedParameterMap;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.w3c.dom.Document;
import org.w3c.dom.Element;
import java.io.ByteArrayOutputStream;
import java.io.IOException;
import java.io.OutputStream;
import java.io.StringWriter;
import java.net.UnknownHostException;
import java.util.ArrayList;
import java.util.Collections;
import java.util.Enumeration;
import java.util.List;
import java.util.zip.GZIPOutputStream;
import javax.servlet.ServletConfig;
import javax.servlet.ServletException;
import javax.servlet.http.Cookie;
import javax.servlet.http.HttpServlet;
import javax.servlet.http.HttpServletRequest;
import javax.servlet.http.HttpServletResponse;
/** Handles the <code>/login</code> URL for web based single-sign-on. */
public class OpenIdLoginServlet extends HttpServlet {
private static final Logger log =
LoggerFactory.getLogger(OpenIdLoginServlet.class);
private static final int LASTID_AGE = 365 * 24 * 60 * 60; // seconds
private static final String AX_SCHEMA = "http://openid.net/srv/ax/1.0";
private static final String OMODE_CANCEL = "cancel";
private static final String GMODE_CHKCOOKIE = "gerrit.chkcookie";
private static final String GMODE_SETCOOKIE = "gerrit.setcookie";
private RelyingParty relyingParty;
private GerritServer server;
private Document pleaseSetCookieDoc;
@Override
public void init(final ServletConfig config) throws ServletException {
super.init(config);
try {
server = GerritServer.getInstance();
} catch (OrmException e) {
throw new ServletException("Cannot load GerritServer", e);
} catch (XsrfException e) {
throw new ServletException("Cannot load GerritServer", e);
}
try {
final OpenIdContext context = new OpenIdContext();
context.setAssociation(new DiffieHellmanAssociation());
context.setDiscovery(new DefaultDiscovery());
context.setHttpConnector(new SimpleHttpConnector());
relyingParty = new RelyingParty(context, new GerritOpenIdUserManager());
} catch (Throwable e) {
log.error("Cannot setup RelyingParty", e);
throw new ServletException("Cannot setup RelyingParty", e);
}
final String scHtmlName = "com/google/gerrit/public/SetCookie.html";
pleaseSetCookieDoc = HtmlDomUtil.parseFile(scHtmlName);
if (pleaseSetCookieDoc == null) {
log.error("No " + scHtmlName + " in CLASSPATH");
throw new ServletException("No " + scHtmlName + " in CLASSPATH");
}
}
@Override
public void doGet(final HttpServletRequest req, final HttpServletResponse rsp)
throws IOException {
doPost(req, rsp);
}
@Override
public void doPost(final HttpServletRequest req, final HttpServletResponse rsp)
throws IOException {
try {
doAuth(req, rsp);
} catch (Exception e) {
getServletContext().log("Unexpected error during authentication", e);
callback(req, rsp, SignInResult.CANCEL);
}
}
private void doAuth(final HttpServletRequest req,
final HttpServletResponse rsp) throws Exception {
if (false) {
System.out.println(req.getMethod() + " /login");
for (final Enumeration e = req.getParameterNames(); e.hasMoreElements();) {
final String n = (String) e.nextElement();
for (final String v : req.getParameterValues(n)) {
System.out.println(" " + n + "=" + v);
}
}
System.out.println();
}
final String mode = req.getParameter(Constants.OPENID_MODE);
if (OMODE_CANCEL.equals(mode)) {
// Provider wants us to cancel the attempt.
//
callback(req, rsp, SignInResult.CANCEL);
return;
} else if (GMODE_CHKCOOKIE.equals(mode)) {
modeChkSetCookie(req, rsp, true);
return;
} else if (GMODE_SETCOOKIE.equals(mode)) {
modeChkSetCookie(req, rsp, false);
return;
}
final OpenIdUser user;
try {
user = relyingParty.discover(req);
} catch (UnknownHostException u) {
// The remote host described in the OpenID doesn't exist, so we
// can't try to perform discovery against it.
//
callback(req, rsp, SignInResult.CANCEL);
return;
}
if (user == null) {
// User isn't known, no provider is known.
//
callback(req, rsp, SignInResult.CANCEL);
return;
}
if (user.isAssociated() && RelyingParty.isAuthResponse(req)) {
if (!relyingParty.verifyAuth(user, req, rsp)) {
// Failed verification... re-authenticate.
//
callback(req, rsp, SignInResult.CANCEL);
return;
}
// Authentication was successful.
//
String fullname = req.getParameter("openid.sreg.fullname");
String email = req.getParameter("openid.sreg.email");
for (int i = 1;; i++) {
final String nskey = "openid.ns.ext" + i;
final String nsval = req.getParameter(nskey);
if (nsval == null) {
break;
}
final String ext = "openid.ext" + i + ".";
if (AX_SCHEMA.equals(nsval)
&& "fetch_response".equals(req.getParameter(ext + "mode"))) {
final String ax_fname = req.getParameter(ext + "value.fname");
final String ax_email = req.getParameter(ext + "value.email");
if (fullname == null && ax_fname != null) {
fullname = ax_fname;
}
if (email == null && ax_email != null) {
email = ax_email;
}
break;
}
}
initializeAccount(req, rsp, user, fullname, email);
return;
}
if (!relyingParty.getOpenIdContext().getAssociation().associate(user,
relyingParty.getOpenIdContext())) {
// Failed association. Try again.
//
callback(req, rsp, SignInResult.CANCEL);
return;
}
// Authenticate user through his/her OpenID provider
//
final String realm = serverUrl(req);
final UrlEncodedParameterMap retTo =
new UrlEncodedParameterMap(req.getRequestURL().toString());
save(retTo, req, OpenIdUtil.P_SIGNIN_CB);
save(retTo, req, OpenIdUtil.P_SIGNIN_MODE);
save(retTo, req, OpenIdUtil.P_REMEMBERID);
final UrlEncodedParameterMap auth;
auth = RelyingParty.getAuthUrlMap(user, realm, realm, retTo.toString());
auth.put("openid.ns.ext1", AX_SCHEMA);
final String ext1 = "openid.ext1.";
auth.put(ext1 + "mode", "fetch_request");
auth.put(ext1 + "type.fname", "http://example.com/schema/fullname");
auth.put(ext1 + "type.email", "http://schema.openid.net/contact/email");
auth.put(ext1 + "required", "email");
auth.put(ext1 + "if_available", "fname");
auth.put("openid.sreg.optional", "fullname,email");
auth.put("openid.ns.sreg", "http://openid.net/extensions/sreg/1.1");
relyingParty.getOpenIdUserManager().saveUser(user, req, rsp);
sendJson(req, rsp, new DiscoveryResult(true, auth.getUrl(), auth), req
.getParameter(OpenIdUtil.P_DISCOVERY_CB));
}
private static void save(UrlEncodedParameterMap b, HttpServletRequest r,
String n) {
final String v = r.getParameter(n);
if (v != null) {
b.put(n, v);
}
}
private void initializeAccount(final HttpServletRequest req,
final HttpServletResponse rsp, final OpenIdUser 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) {
getServletContext().log("Account lookup failed", e);
account = null;
}
}
rsp.reset();
String tok;
try {
final String idstr = String.valueOf(account.getId().get());
tok = server.getAccountToken().newToken(idstr);
} catch (XsrfException e) {
getServletContext().log("Account cookie signature impossible", e);
account = null;
tok = "";
}
Cookie c = new Cookie(Gerrit.ACCOUNT_COOKIE, tok);
c.setPath(req.getContextPath() + "/");
if (account == null) {
if (mode == SignInDialog.Mode.SIGN_IN) {
c.setMaxAge(0);
rsp.addCookie(c);
}
callback(req, rsp, SignInResult.CANCEL);
} else {
c.setMaxAge(server.getSessionAge());
rsp.addCookie(c);
final UrlEncodedParameterMap me =
new UrlEncodedParameterMap(req.getRequestURL().toString());
me.put(Constants.OPENID_MODE, GMODE_CHKCOOKIE);
me.put(Gerrit.ACCOUNT_COOKIE, tok);
save(me, req, OpenIdUtil.P_SIGNIN_CB);
save(me, req, OpenIdUtil.P_SIGNIN_MODE);
if ("on".equals(req.getParameter(OpenIdUtil.P_REMEMBERID))) {
final String ident = saveLastId(req, rsp, user.getClaimedId());
me.put(OpenIdUtil.LASTID_COOKIE, ident);
save(me, req, OpenIdUtil.P_REMEMBERID);
} else {
c = new Cookie(OpenIdUtil.LASTID_COOKIE, "");
c.setPath(req.getContextPath() + "/");
c.setMaxAge(0);
rsp.addCookie(c);
}
rsp.sendRedirect(me.toString());
}
}
private String saveLastId(final HttpServletRequest req,
final HttpServletResponse rsp, String ident) {
final Cookie c = new Cookie(OpenIdUtil.LASTID_COOKIE, ident);
c.setPath(req.getContextPath() + "/");
c.setMaxAge(LASTID_AGE);
rsp.addCookie(c);
return ident;
}
private Account openAccount(final ReviewDb db, final OpenIdUser user,
final String fullname, final String email) throws OrmException {
Account account;
final AccountExternalIdAccess extAccess = db.accountExternalIds();
AccountExternalId acctExt = lookup(extAccess, user.getClaimedId());
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.getClaimedId()));
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
.getClaimedId()));
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 OpenIdUser 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 = Account.Id.parse(tok.getData());
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.getClaimedId());
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) {
final String p = req.getParameter(OpenIdUtil.P_SIGNIN_MODE);
if (p == null || p.length() == 0) {
return SignInDialog.Mode.SIGN_IN;
}
try {
return SignInDialog.Mode.valueOf(p);
} 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 OpenIdUser user) {
return user.getClaimedId().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 void modeChkSetCookie(final HttpServletRequest req,
final HttpServletResponse rsp, final boolean isCheck) throws IOException {
final String exp = req.getParameter(Gerrit.ACCOUNT_COOKIE);
final ValidToken chk;
try {
chk = server.getAccountToken().checkToken(exp, null);
if (chk == null) {
callback(req, rsp, SignInResult.CANCEL);
return;
}
} catch (XsrfException e) {
getServletContext().log("Cannot validate cookie token", e);
callback(req, rsp, SignInResult.CANCEL);
return;
}
final Account.Id id;
try {
id = new Account.Id(Integer.parseInt(chk.getData()));
} catch (NumberFormatException e) {
callback(req, rsp, SignInResult.CANCEL);
return;
}
final String act = getCookie(req, Gerrit.ACCOUNT_COOKIE);
if (isCheck && !exp.equals(act)) {
// Cookie won't set without "user interaction" (thanks Safari). Lets
// send an HTML page to the browser and ask the user to click to let
// us set the cookie.
//
sendSetCookieHtml(req, rsp, exp);
return;
}
Cookie c = new Cookie(Gerrit.ACCOUNT_COOKIE, exp);
c.setPath(req.getContextPath() + "/");
c.setMaxAge(server.getSessionAge());
rsp.addCookie(c);
if ("on".equals(req.getParameter(OpenIdUtil.P_REMEMBERID))) {
saveLastId(req, rsp, req.getParameter(OpenIdUtil.LASTID_COOKIE));
} else {
c = new Cookie(OpenIdUtil.LASTID_COOKIE, "");
c.setPath(req.getContextPath() + "/");
c.setMaxAge(0);
rsp.addCookie(c);
}
callback(req, rsp, new SignInResult(SignInResult.Status.SUCCESS));
}
private void sendSetCookieHtml(final HttpServletRequest req,
final HttpServletResponse rsp, final String exp) throws IOException {
final Document doc = HtmlDomUtil.clone(pleaseSetCookieDoc);
final Element set_form = HtmlDomUtil.find(doc, "set_form");
set_form.setAttribute("action", req.getRequestURL().toString());
HtmlDomUtil.addHidden(set_form, Constants.OPENID_MODE, GMODE_SETCOOKIE);
HtmlDomUtil.addHidden(set_form, Gerrit.ACCOUNT_COOKIE, exp);
save(set_form, req, OpenIdUtil.LASTID_COOKIE);
save(set_form, req, OpenIdUtil.P_REMEMBERID);
save(set_form, req, OpenIdUtil.P_SIGNIN_CB);
save(set_form, req, OpenIdUtil.P_SIGNIN_MODE);
sendHtml(req, rsp, HtmlDomUtil.toString(doc));
}
private static void save(Element f, HttpServletRequest r, String n) {
final String v = r.getParameter(n);
if (v != null) {
HtmlDomUtil.addHidden(f, n, v);
}
}
private static String getCookie(final HttpServletRequest req,
final String name) {
final Cookie[] allCookies = req.getCookies();
if (allCookies != null) {
for (final Cookie c : allCookies) {
if (name.equals(c.getName())) {
return c.getValue();
}
}
}
return null;
}
private void callback(final HttpServletRequest req,
final HttpServletResponse rsp, final SignInResult result)
throws IOException {
final String dcb = req.getParameter(OpenIdUtil.P_DISCOVERY_CB);
if (dcb != null) {
// We're in the middle of a discovery request; we need to use
// the discovery request callback and not the sign in callback.
//
sendJson(req, rsp, new DiscoveryResult(false), dcb);
return;
}
// We're not in an OpenID transaction so try to clear out the
// OpenID management cookie.
//
relyingParty.getOpenIdUserManager().invalidate(req, rsp);
final String cb = req.getParameter(OpenIdUtil.P_SIGNIN_CB);
if (cb != null && cb.startsWith("history:")) {
final StringBuffer rdr = req.getRequestURL();
rdr.setLength(rdr.lastIndexOf("/"));
rdr.append("/Gerrit");
rdr.append('#');
rdr.append(cb.substring("history:".length()));
rsp.sendRedirect(rdr.toString());
return;
}
sendJson(req, rsp, result, cb);
}
private void sendJson(final HttpServletRequest req,
final HttpServletResponse rsp, final Object result, final String cb)
throws IOException {
final StringWriter body = new StringWriter();
body.write("<html>");
body.append("<body>");
if (JsonServlet.SAFE_CALLBACK.matcher(cb).matches()) {
body.write("<script><!--\n");
body.write(cb);
body.write("(");
JsonServlet.defaultGsonBuilder().create().toJson(result, body);
body.write(");\n");
body.write("// -->\n");
body.write("</script>");
body.write("<p>Loading ...</p>");
} else {
body.append("Unsafe JSON callback requested; refusing to execute it.");
}
body.append("</body>");
body.write("</html>");
sendHtml(req, rsp, body.toString());
}
private void sendHtml(final HttpServletRequest req,
final HttpServletResponse rsp, final String bodystr) throws IOException {
final byte[] raw = bodystr.getBytes(HtmlDomUtil.ENC);
final byte[] tosend;
if (RPCServletUtils.acceptsGzipEncoding(req)) {
rsp.setHeader("Content-Encoding", "gzip");
final ByteArrayOutputStream compressed = new ByteArrayOutputStream();
final GZIPOutputStream gz = new GZIPOutputStream(compressed);
gz.write(raw);
gz.finish();
gz.flush();
tosend = compressed.toByteArray();
} else {
tosend = raw;
}
rsp.setCharacterEncoding(HtmlDomUtil.ENC);
rsp.setContentType("text/html");
rsp.setHeader("Expires", "Fri, 01 Jan 1980 00:00:00 GMT");
rsp.setHeader("Pragma", "no-cache");
rsp.setHeader("Cache-Control", "no-cache, must-revalidate");
rsp.setContentLength(tosend.length);
final OutputStream out = rsp.getOutputStream();
try {
out.write(tosend);
} finally {
out.close();
}
}
static String serverUrl(final HttpServletRequest req) {
// Assume this servlet is in the context with a simple name like "login"
// and we were accessed without any path info. Clipping the last part of
// the name from the URL should generate the web application's root path.
//
final String uri = req.getRequestURL().toString();
final int s = uri.lastIndexOf('/');
return s >= 0 ? uri.substring(0, s + 1) : uri;
}
}