Allow users to link additional web identities to their Gerrit account

This is really useful with Google Accounts, where the user might have
more than one Google Account identity in their browser.  If they link
all of them back to the same Gerrit account then it doesn't matter who
they are currently logged in as in their browser, they can still get
to the same dashboard and the same identity within Gerrit, without a
forced logout/login cycle on the google.com domain.

We store the email address uniquely with each identity so the user
can more easily tell them apart.  In the case of Google Accounts the
identity string is too opaque for an end-user to deduce which account
is which without having the email along side of it.

Signed-off-by: Shawn O. Pearce <sop@google.com>
This commit is contained in:
Shawn O. Pearce
2008-12-30 11:09:03 -08:00
parent bd523456fc
commit bd5d5ef823
10 changed files with 349 additions and 48 deletions

View File

@@ -46,8 +46,15 @@ import com.google.gwtjsonrpc.client.CallbackHandle;
* completed).
*/
public class SignInDialog extends AutoCenterDialogBox {
public static final String SIGNIN_MODE_PARAM = "gerrit.signin_mode";
public static enum Mode {
SIGN_IN, LINK_IDENTIY;
}
private static SignInDialog current;
private Mode mode = Mode.SIGN_IN;
private final CallbackHandle<SignInResult> signInCallback;
private final AsyncCallback<?> appCallback;
private final Frame loginFrame;
@@ -76,6 +83,10 @@ public class SignInDialog extends AutoCenterDialogBox {
setText(Gerrit.C.signInDialogTitle());
}
public void setMode(final Mode m) {
mode = m;
}
@Override
protected void onResize(final int width, final int height) {
resizeFrame(width, height);
@@ -105,6 +116,10 @@ public class SignInDialog extends AutoCenterDialogBox {
url.append("login");
url.append("?");
url.append("callback=parent." + signInCallback.getFunctionName());
url.append("&");
url.append(SIGNIN_MODE_PARAM);
url.append("=");
url.append(mode.name());
loginFrame.setUrl(url.toString());
}

View File

@@ -26,6 +26,7 @@ public interface AccountConstants extends Constants {
String tabPreferences();
String tabSshKeys();
String tabWebIdentities();
String tabAgreements();
String buttonDeleteSshKey();
@@ -40,6 +41,11 @@ public interface AccountConstants extends Constants {
String addSshKeyPanelHeader();
String addSshKeyHelp();
String webIdLastUsed();
String webIdEmail();
String webIdIdentity();
String buttonLinkIdentity();
String watchedProjects();
String buttonWatchProject();
String defaultProjectName();

View File

@@ -7,6 +7,7 @@ defaultContext = Default Context
tabPreferences = Preferences
tabSshKeys = SSH Keys
tabWebIdentities = Web Identities
tabAgreements = Agreements
buttonDeleteSshKey = Delete
@@ -18,6 +19,11 @@ sshKeyComment = Comment
sshKeyLastUsed = Last Used
sshKeyStored = Stored
webIdLastUsed = Last Login
webIdEmail = Email Address
webIdIdentity = Identity
buttonLinkIdentity = Link Another Identity
addSshKeyPanelHeader = Add SSH Public Key
addSshKeyHelp = (<a href="http://github.com/guides/providing-your-ssh-key" target="_blank">GitHub's Guide to SSH Keys</a>)

View File

@@ -14,6 +14,7 @@
package com.google.gerrit.client.account;
import com.google.gerrit.client.reviewdb.AccountExternalId;
import com.google.gerrit.client.reviewdb.AccountSshKey;
import com.google.gerrit.client.rpc.SignInRequired;
import com.google.gwt.user.client.rpc.AsyncCallback;
@@ -33,4 +34,7 @@ public interface AccountSecurity extends RemoteJsonService {
@SignInRequired
void deleteSshKeys(Set<AccountSshKey.Id> ids,
AsyncCallback<VoidResult> callback);
@SignInRequired
void myExternalIds(AsyncCallback<List<AccountExternalId>> callback);
}

View File

@@ -76,6 +76,12 @@ public class AccountSettings extends AccountScreen {
return new SshKeyPanel();
}
}, Util.C.tabSshKeys());
tabs.add(new LazyTabChild<ExternalIdPanel>() {
@Override
protected ExternalIdPanel create() {
return new ExternalIdPanel();
}
}, Util.C.tabWebIdentities());
tabs.add(agreementsPanel, Util.C.tabAgreements());
add(tabs);

View File

@@ -0,0 +1,129 @@
// Copyright 2008 Google Inc.
//
// 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.client.account;
import com.google.gerrit.client.FormatUtil;
import com.google.gerrit.client.SignInDialog;
import com.google.gerrit.client.reviewdb.AccountExternalId;
import com.google.gerrit.client.rpc.GerritCallback;
import com.google.gerrit.client.ui.FancyFlexTable;
import com.google.gwt.user.client.ui.Button;
import com.google.gwt.user.client.ui.ClickListener;
import com.google.gwt.user.client.ui.Composite;
import com.google.gwt.user.client.ui.FlowPanel;
import com.google.gwt.user.client.ui.SourcesTableEvents;
import com.google.gwt.user.client.ui.TableListener;
import com.google.gwt.user.client.ui.Widget;
import com.google.gwt.user.client.ui.FlexTable.FlexCellFormatter;
import java.util.List;
class ExternalIdPanel extends Composite {
private IdTable identites;
private Button linkIdentity;
ExternalIdPanel() {
final FlowPanel body = new FlowPanel();
identites = new IdTable();
body.add(identites);
linkIdentity = new Button(Util.C.buttonLinkIdentity());
linkIdentity.addClickListener(new ClickListener() {
public void onClick(final Widget sender) {
doLinkIdentity();
}
});
body.add(linkIdentity);
initWidget(body);
}
void doLinkIdentity() {
final SignInDialog d = new SignInDialog(new GerritCallback<Object>() {
public void onSuccess(final Object result) {
refresh();
}
});
d.setMode(SignInDialog.Mode.LINK_IDENTIY);
d.show();
}
@Override
public void onLoad() {
super.onLoad();
refresh();
}
private void refresh() {
Util.ACCOUNT_SEC
.myExternalIds(new GerritCallback<List<AccountExternalId>>() {
public void onSuccess(final List<AccountExternalId> result) {
identites.display(result);
identites.finishDisplay(true);
}
});
}
private class IdTable extends FancyFlexTable<AccountExternalId> {
IdTable() {
table.setText(0, 1, Util.C.webIdLastUsed());
table.setText(0, 2, Util.C.webIdEmail());
table.setText(0, 3, Util.C.webIdIdentity());
table.addTableListener(new TableListener() {
public void onCellClicked(SourcesTableEvents sender, int row, int cell) {
movePointerTo(row);
}
});
final FlexCellFormatter fmt = table.getFlexCellFormatter();
fmt.addStyleName(0, 1, S_DATA_HEADER);
fmt.addStyleName(0, 2, S_DATA_HEADER);
fmt.addStyleName(0, 3, S_DATA_HEADER);
}
@Override
protected Object getRowItemKey(final AccountExternalId item) {
return item.getKey();
}
void display(final List<AccountExternalId> result) {
while (1 < table.getRowCount())
table.removeRow(table.getRowCount() - 1);
for (final AccountExternalId k : result) {
addOneId(k);
}
}
void addOneId(final AccountExternalId k) {
final int row = table.getRowCount();
table.insertRow(row);
table.setText(row, 1, FormatUtil.mediumFormat(k.getLastUsedOn()));
table.setText(row, 2, k.getEmailAddress());
table.setText(row, 3, k.getExternalId());
final FlexCellFormatter fmt = table.getFlexCellFormatter();
fmt.addStyleName(row, 1, S_DATA_CELL);
fmt.addStyleName(row, 1, "C_LAST_UPDATE");
fmt.addStyleName(row, 2, S_DATA_CELL);
fmt.addStyleName(row, 3, S_DATA_CELL);
setRowItem(row, k);
}
}
}

View File

@@ -17,6 +17,8 @@ package com.google.gerrit.client.reviewdb;
import com.google.gwtorm.client.Column;
import com.google.gwtorm.client.StringKey;
import java.sql.Timestamp;
/** Association of an external account identifier to a local {@link Account}. */
public final class AccountExternalId {
public static class Key extends StringKey<Account.Id> {
@@ -54,6 +56,12 @@ public final class AccountExternalId {
@Column(name = Column.NONE)
protected Key key;
@Column(notNull = false)
protected String emailAddress;
@Column(notNull = false)
protected Timestamp lastUsedOn;
protected AccountExternalId() {
}
@@ -66,8 +74,32 @@ public final class AccountExternalId {
key = k;
}
public AccountExternalId.Key getKey() {
return key;
}
/** Get local id of this account, to link with in other entities */
public Account.Id getAccountId() {
return key.accountId;
}
public String getExternalId() {
return key.externalId;
}
public String getEmailAddress() {
return emailAddress;
}
public void setEmailAddress(final String e) {
emailAddress = e;
}
public Timestamp getLastUsedOn() {
return lastUsedOn;
}
public void setLastUsedOn() {
lastUsedOn = new Timestamp(System.currentTimeMillis());
}
}

View File

@@ -16,6 +16,7 @@ package com.google.gerrit.server;
import com.google.gerrit.client.account.AccountSecurity;
import com.google.gerrit.client.reviewdb.Account;
import com.google.gerrit.client.reviewdb.AccountExternalId;
import com.google.gerrit.client.reviewdb.AccountSshKey;
import com.google.gerrit.client.reviewdb.ReviewDb;
import com.google.gerrit.client.rpc.BaseServiceImplementation;
@@ -104,4 +105,13 @@ public class AccountSecurityImpl extends BaseServiceImplementation implements
}
});
}
public void myExternalIds(AsyncCallback<List<AccountExternalId>> callback) {
run(callback, new Action<List<AccountExternalId>>() {
public List<AccountExternalId> run(ReviewDb db) throws OrmException {
final Account.Id me = RpcUtil.getAccountId();
return db.accountExternalIds().byAccount(me).toList();
}
});
}
}

View File

@@ -15,6 +15,8 @@
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.reviewdb.Account;
import com.google.gerrit.client.reviewdb.AccountExternalId;
@@ -54,6 +56,8 @@ import javax.servlet.http.HttpServletResponse;
/** Handles the <code>/login</code> URL for web based single-sign-on. */
public class LoginServlet extends HttpServlet {
private static final String SIGNIN_MODE_PARAMETER =
SignInDialog.SIGNIN_MODE_PARAM;
private static final String CALLBACK_PARMETER = "callback";
private static final String AX_SCHEMA = "http://openid.net/srv/ax/1.0";
private static final String GMODE_CHKCOOKIE = "gerrit_chkcookie";
@@ -195,6 +199,7 @@ public class LoginServlet extends HttpServlet {
final String realm = serverUrl(req);
final StringBuilder retTo = new StringBuilder(req.getRequestURL());
append(retTo, CALLBACK_PARMETER, req.getParameter(CALLBACK_PARMETER));
append(retTo, SIGNIN_MODE_PARAMETER, signInMode(req).name());
final StringBuilder auth;
auth = RelyingParty.getAuthUrlBuffer(user, realm, realm, retTo.toString());
@@ -218,6 +223,7 @@ public class LoginServlet extends HttpServlet {
//
final StringBuilder url = new StringBuilder(req.getRequestURL());
append(url, CALLBACK_PARMETER, req.getParameter(CALLBACK_PARMETER));
append(url, SIGNIN_MODE_PARAMETER, signInMode(req).name());
append(url, RelyingParty.DEFAULT_PARAMETER,
GoogleAccountDiscovery.GOOGLE_ACCOUNT);
rsp.sendRedirect(url.toString());
@@ -226,55 +232,19 @@ public class LoginServlet extends HttpServlet {
private void initializeAccount(final HttpServletRequest req,
final HttpServletResponse rsp, final OpenIdUser user, final String email)
throws IOException {
final SignInDialog.Mode mode = signInMode(req);
Account account = null;
if (user != null) {
try {
final ReviewDb d = server.getDatabase().open();
try {
final AccountExternalIdAccess extAccess = d.accountExternalIds();
AccountExternalId acctExt = lookup(extAccess, user.getIdentity());
if (acctExt == null && email != null && isGoogleAccount(user)) {
acctExt = lookup(extAccess, "GoogleAccount/" + email);
if (acctExt != null) {
// Legacy user from Gerrit 1? Attach the OpenID identity.
//
final AccountExternalId openidExt =
new AccountExternalId(new AccountExternalId.Key(acctExt
.getAccountId(), user.getIdentity()));
extAccess.insert(Collections.singleton(openidExt));
acctExt = openidExt;
}
}
if (acctExt != null) {
account = d.accounts().get(acctExt.getAccountId());
} else {
account = null;
}
if (account != null) {
// Existing user; double check the email is current.
//
if (email != null && !email.equals(account.getPreferredEmail())) {
account.setPreferredEmail(email);
d.accounts().update(Collections.singleton(account));
}
} else {
// New user; create an account entity for them.
//
final Transaction txn = d.beginTransaction();
account = new Account(new Account.Id(d.nextAccountId()));
account.setPreferredEmail(email);
acctExt =
new AccountExternalId(new AccountExternalId.Key(
account.getId(), user.getIdentity()));
d.accounts().insert(Collections.singleton(account), txn);
extAccess.insert(Collections.singleton(acctExt), txn);
txn.commit();
switch (mode) {
case SIGN_IN:
account = openAccount(d, user, email);
break;
case LINK_IDENTIY:
account = linkAccount(req, d, user, email);
break;
}
} finally {
d.close();
@@ -306,8 +276,10 @@ public class LoginServlet extends HttpServlet {
c.setPath(req.getContextPath() + "/");
if (account == null) {
c.setMaxAge(0);
rsp.addCookie(c);
if (mode == SignInDialog.Mode.SIGN_IN) {
c.setMaxAge(0);
rsp.addCookie(c);
}
callback(req, rsp, SignInResult.CANCEL);
} else {
c.setMaxAge(server.getSessionAge());
@@ -317,10 +289,127 @@ public class LoginServlet extends HttpServlet {
append(me, Constants.OPENID_MODE, GMODE_CHKCOOKIE);
append(me, CALLBACK_PARMETER, req.getParameter(CALLBACK_PARMETER));
append(me, Gerrit.ACCOUNT_COOKIE, tok);
append(me, SIGNIN_MODE_PARAMETER, mode.name());
rsp.sendRedirect(me.toString());
}
}
private Account openAccount(final ReviewDb db, final OpenIdUser user,
final String email) throws OrmException {
Account account;
final AccountExternalIdAccess extAccess = db.accountExternalIds();
AccountExternalId acctExt = lookup(extAccess, user.getIdentity());
if (acctExt == null && email != null && isGoogleAccount(user)) {
acctExt = lookup(extAccess, "GoogleAccount/" + email);
if (acctExt != null) {
// Legacy user from Gerrit 1? Attach the OpenID identity.
//
final AccountExternalId openidExt =
new AccountExternalId(new AccountExternalId.Key(acctExt
.getAccountId(), user.getIdentity()));
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 = db.accounts().get(acctExt.getAccountId());
} 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.setPreferredEmail(email);
acctExt =
new AccountExternalId(new AccountExternalId.Key(account.getId(), user
.getIdentity()));
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 = db.accounts().get(me);
if (account == null) {
return null;
}
final AccountExternalId.Key idKey =
new AccountExternalId.Key(account.getId(), user.getIdentity());
AccountExternalId id = db.accountExternalIds().get(idKey);
if (id == null) {
id = new AccountExternalId(idKey);
id.setLastUsedOn();
id.setEmailAddress(email);
db.accountExternalIds().insert(Collections.singleton(id));
} 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(SIGNIN_MODE_PARAMETER);
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 {
@@ -402,6 +491,8 @@ public class LoginServlet extends HttpServlet {
HtmlDomUtil.addHidden(set_form, Gerrit.ACCOUNT_COOKIE, exp);
HtmlDomUtil.addHidden(set_form, CALLBACK_PARMETER, req
.getParameter(CALLBACK_PARMETER));
HtmlDomUtil.addHidden(set_form, SIGNIN_MODE_PARAMETER, signInMode(req)
.name());
sendHtml(req, rsp, HtmlDomUtil.toString(doc));
}