Allow users to register additional email addresses

The SSH username cannot be set without having an email address,
and some types of CLAs may require an email address to be set
in the contact information for the user.

However, not all OpenID providers track or transmit emails to
relying parties, so users must still be able to register an
address externally from their OpenID system.  We now send out
a validation email and use a secure token to verify the address
matches where the user sent it to.  A "mailto:" URL is used in
the external id table to register the additional address, thus
making it available as a choice for the user in the settings.

Signed-off-by: Shawn O. Pearce <sop@google.com>
This commit is contained in:
Shawn O. Pearce
2009-01-15 11:04:12 -08:00
parent ffe31648db
commit e76e0ab31b
10 changed files with 332 additions and 8 deletions

View File

@@ -16,6 +16,7 @@ package com.google.gerrit.client;
import com.google.gerrit.client.account.AccountSettings;
import com.google.gerrit.client.account.NewAgreementScreen;
import com.google.gerrit.client.account.ValidateEmailScreen;
import com.google.gerrit.client.admin.AccountGroupScreen;
import com.google.gerrit.client.admin.GroupListScreen;
import com.google.gerrit.client.admin.ProjectAdminScreen;
@@ -167,6 +168,11 @@ public class Link implements HistoryListener {
}
}
p = "VE,";
if (token.startsWith(p)) {
return new ValidateEmailScreen(skip(p, token));
}
return null;
}

View File

@@ -59,6 +59,10 @@ public interface AccountConstants extends Constants {
String contactFieldPhone();
String contactFieldFax();
String buttonSaveContact();
String buttonOpenRegisterNewEmail();
String buttonSendRegisterNewEmail();
String titleRegisterNewEmail();
String descRegisterNewEmail();
String newAgreement();
String agreementStatus();

View File

@@ -40,6 +40,13 @@ contactFieldCountry = Country
contactFieldPhone = Phone Number
contactFieldFax = Fax Number
buttonSaveContact = Save
buttonOpenRegisterNewEmail = Register New Email ...
buttonSendRegisterNewEmail = Register
titleRegisterNewEmail = Register Email Address
descRegisterNewEmail = \
<p>A confirmation link will be sent by email to this address.</p>\
<p>You must click on the link to complete the registration and make the address available for selection.</p>
newAgreement = New Contributor Agreement
agreementStatus = Status

View File

@@ -47,4 +47,10 @@ public interface AccountSecurity extends RemoteJsonService {
@SignInRequired
void enterAgreement(ContributorAgreement.Id id,
AsyncCallback<VoidResult> callback);
@SignInRequired
void registerEmail(String address, AsyncCallback<VoidResult> callback);
@SignInRequired
void validateEmail(String token, AsyncCallback<VoidResult> callback);
}

View File

@@ -19,6 +19,7 @@ import com.google.gerrit.client.reviewdb.Account;
import com.google.gerrit.client.reviewdb.AccountExternalId;
import com.google.gerrit.client.reviewdb.ContactInformation;
import com.google.gerrit.client.rpc.GerritCallback;
import com.google.gerrit.client.ui.AutoCenterDialogBox;
import com.google.gerrit.client.ui.TextSaveButtonListener;
import com.google.gwt.i18n.client.LocaleInfo;
import com.google.gwt.user.client.ui.Button;
@@ -26,10 +27,16 @@ import com.google.gwt.user.client.ui.ChangeListener;
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.FormHandler;
import com.google.gwt.user.client.ui.FormPanel;
import com.google.gwt.user.client.ui.FormSubmitCompleteEvent;
import com.google.gwt.user.client.ui.FormSubmitEvent;
import com.google.gwt.user.client.ui.Grid;
import com.google.gwt.user.client.ui.HTML;
import com.google.gwt.user.client.ui.ListBox;
import com.google.gwt.user.client.ui.TextArea;
import com.google.gwt.user.client.ui.TextBox;
import com.google.gwt.user.client.ui.VerticalPanel;
import com.google.gwt.user.client.ui.Widget;
import com.google.gwt.user.client.ui.HTMLTable.CellFormatter;
import com.google.gwtjsonrpc.client.VoidResult;
@@ -51,6 +58,7 @@ class ContactPanel extends Composite {
private TextBox nameTxt;
private ListBox emailPick;
private Button registerNewEmail;
private TextArea addressTxt;
private TextBox countryTxt;
private TextBox phoneTxt;
@@ -99,8 +107,19 @@ class ContactPanel extends Composite {
info.addStyleName("gerrit-AccountInfoBlock");
body.add(info);
registerNewEmail = new Button(Util.C.buttonOpenRegisterNewEmail());
registerNewEmail.setEnabled(false);
registerNewEmail.addClickListener(new ClickListener() {
public void onClick(final Widget sender) {
doRegisterNewEmail();
}
});
final FlowPanel emailLine = new FlowPanel();
emailLine.add(emailPick);
emailLine.add(registerNewEmail);
row(0, Util.C.contactFieldFullName(), nameTxt);
row(1, Util.C.contactFieldEmail(), emailPick);
row(1, Util.C.contactFieldEmail(), emailLine);
row(2, Util.C.contactFieldAddress(), addressTxt);
row(3, Util.C.contactFieldCountry(), countryTxt);
row(4, Util.C.contactFieldPhone(), phoneTxt);
@@ -124,7 +143,17 @@ class ContactPanel extends Composite {
nameTxt.addKeyboardListener(sbl);
emailPick.addChangeListener(new ChangeListener() {
public void onChange(Widget sender) {
save.setEnabled(true);
final int idx = emailPick.getSelectedIndex();
final String v = 0 <= idx ? emailPick.getValue(idx) : null;
if (Util.C.buttonOpenRegisterNewEmail().equals(v)) {
for (int i = 0; i < emailPick.getItemCount(); i++) {
if (currentEmail.equals(emailPick.getValue(i))) {
emailPick.setSelectedIndex(i);
break;
}
}
doRegisterNewEmail();
}
}
});
addressTxt.addKeyboardListener(sbl);
@@ -146,6 +175,7 @@ class ContactPanel extends Composite {
emailPick.clear();
emailPick.setEnabled(false);
registerNewEmail.setEnabled(false);
haveAccount = false;
haveEmails = false;
@@ -200,7 +230,15 @@ class ContactPanel extends Composite {
emailPick.setSelectedIndex(emailPick.getItemCount() - 1);
}
}
emailPick.setEnabled(true);
if (emailPick.getItemCount() > 0) {
emailPick.setVisible(true);
emailPick.setEnabled(true);
emailPick.addItem("... " + Util.C.buttonOpenRegisterNewEmail()
+ " ", Util.C.buttonOpenRegisterNewEmail());
} else {
emailPick.setVisible(false);
}
registerNewEmail.setEnabled(true);
}
}
@@ -225,11 +263,69 @@ class ContactPanel extends Composite {
save.setEnabled(false);
}
private void doRegisterNewEmail() {
final AutoCenterDialogBox box = new AutoCenterDialogBox(true, true);
final VerticalPanel body = new VerticalPanel();
final TextBox inEmail = new TextBox();
inEmail.setVisibleLength(60);
final Button register = new Button(Util.C.buttonSendRegisterNewEmail());
final FormPanel form = new FormPanel();
form.addFormHandler(new FormHandler() {
public void onSubmit(final FormSubmitEvent event) {
event.setCancelled(true);
final String addr = inEmail.getText().trim();
if (!addr.contains("@")) {
return;
}
inEmail.setEnabled(false);
register.setEnabled(false);
Util.ACCOUNT_SEC.registerEmail(addr, new GerritCallback<VoidResult>() {
public void onSuccess(VoidResult result) {
box.hide();
}
@Override
public void onFailure(final Throwable caught) {
inEmail.setEnabled(true);
register.setEnabled(true);
super.onFailure(caught);
}
});
}
public void onSubmitComplete(final FormSubmitCompleteEvent event) {
}
});
form.setWidget(body);
register.addClickListener(new ClickListener() {
public void onClick(Widget sender) {
form.submit();
}
});
body.add(new HTML(Util.C.descRegisterNewEmail()));
body.add(inEmail);
body.add(register);
box.setText(Util.C.titleRegisterNewEmail());
box.setWidget(form);
box.center();
inEmail.setFocus(true);
}
void doSave() {
final String newName = nameTxt.getText();
final String newEmail;
if (emailPick.isEnabled() && emailPick.getSelectedIndex() >= 0) {
newEmail = emailPick.getValue(emailPick.getSelectedIndex());
final String v = emailPick.getValue(emailPick.getSelectedIndex());
if (Util.C.buttonOpenRegisterNewEmail().equals(v)) {
newEmail = currentEmail;
} else {
newEmail = v;
}
} else {
newEmail = currentEmail;
}
@@ -239,11 +335,13 @@ class ContactPanel extends Composite {
info.setCountry(countryTxt.getText());
info.setPhoneNumber(phoneTxt.getText());
info.setFaxNumber(faxTxt.getText());
save.setEnabled(false);
registerNewEmail.setEnabled(false);
Util.ACCOUNT_SEC.updateContact(newName, newEmail, info,
new GerritCallback<VoidResult>() {
public void onSuccess(final VoidResult result) {
save.setEnabled(false);
registerNewEmail.setEnabled(false);
final Account me = Gerrit.getUserAccount();
me.setFullName(newName);
me.setPreferredEmail(newEmail);
@@ -253,6 +351,13 @@ class ContactPanel extends Composite {
parentScreen.display(me);
}
}
@Override
public void onFailure(final Throwable caught) {
save.setEnabled(true);
registerNewEmail.setEnabled(true);
super.onFailure(caught);
}
});
}
}

View File

@@ -0,0 +1,41 @@
// Copyright 2009 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.Link;
import com.google.gerrit.client.rpc.GerritCallback;
import com.google.gerrit.client.ui.AccountScreen;
import com.google.gwt.user.client.History;
import com.google.gwtjsonrpc.client.VoidResult;
public class ValidateEmailScreen extends AccountScreen {
private final String magicToken;
public ValidateEmailScreen(final String magicToken) {
super(Util.C.accountSettingsHeading());
this.magicToken = magicToken;
}
@Override
public void onLoad() {
super.onLoad();
Util.ACCOUNT_SEC.validateEmail(magicToken,
new GerritCallback<VoidResult>() {
public void onSuccess(final VoidResult result) {
History.newItem(Link.SETTINGS_CONTACT, true);
}
});
}
}

View File

@@ -31,6 +31,10 @@ public interface AccountExternalIdAccess extends
@Query("WHERE key.accountId = ?")
ResultSet<AccountExternalId> byAccount(Account.Id id) throws OrmException;
@Query("WHERE key.accountId = ? AND emailAddress = ?")
ResultSet<AccountExternalId> byAccountEmail(Account.Id id, String email)
throws OrmException;
@Query("WHERE emailAddress = ?")
ResultSet<AccountExternalId> byEmailAddress(String email) throws OrmException;

View File

@@ -28,18 +28,39 @@ import com.google.gerrit.client.rpc.NoSuchEntityException;
import com.google.gerrit.server.ssh.SshUtil;
import com.google.gwt.user.client.rpc.AsyncCallback;
import com.google.gwtjsonrpc.client.VoidResult;
import com.google.gwtjsonrpc.server.ValidToken;
import com.google.gwtjsonrpc.server.XsrfException;
import com.google.gwtorm.client.OrmDuplicateKeyException;
import com.google.gwtorm.client.OrmException;
import com.google.gwtorm.client.Transaction;
import org.spearce.jgit.lib.PersonIdent;
import org.spearce.jgit.util.Base64;
import java.io.UnsupportedEncodingException;
import java.security.NoSuchAlgorithmException;
import java.security.NoSuchProviderException;
import java.security.spec.InvalidKeySpecException;
import java.util.Collections;
import java.util.Date;
import java.util.List;
import java.util.Set;
public class AccountSecurityImpl extends BaseServiceImplementation implements
import javax.mail.Message;
import javax.mail.MessagingException;
import javax.mail.Transport;
import javax.mail.internet.InternetAddress;
import javax.mail.internet.MimeMessage;
import javax.servlet.http.HttpServletRequest;
class AccountSecurityImpl extends BaseServiceImplementation implements
AccountSecurity {
private final GerritServer server;
AccountSecurityImpl(final GerritServer gs) {
server = gs;
}
public void mySshKeys(final AsyncCallback<List<AccountSshKey>> callback) {
run(callback, new Action<List<AccountSshKey>>() {
public List<AccountSshKey> run(ReviewDb db) throws OrmException {
@@ -148,4 +169,127 @@ public class AccountSecurityImpl extends BaseServiceImplementation implements
}
});
}
public void registerEmail(final String address,
final AsyncCallback<VoidResult> cb) {
final PersonIdent gi = server.newGerritPersonIdent();
final HttpServletRequest req =
GerritJsonServlet.getCurrentCall().getHttpServletRequest();
final StringBuffer url = req.getRequestURL();
final StringBuilder m = new StringBuilder();
url.setLength(url.lastIndexOf("/")); // cut "AccountSecurity"
url.setLength(url.lastIndexOf("/")); // cut "rpc"
url.append("/Gerrit#VE,");
try {
url.append(server.getEmailRegistrationToken().newToken(
Base64.encodeBytes(address.getBytes("UTF-8"))));
} catch (XsrfException e) {
cb.onFailure(e);
return;
} catch (UnsupportedEncodingException e) {
cb.onFailure(e);
return;
}
m.append("Welcome to Gerrit Code Review at ");
m.append(req.getServerName());
m.append(".\n");
m.append("\n");
m.append("To add a verified email address to your user account, please\n");
m.append("click on the following link:\n");
m.append("\n");
m.append(url);
m.append("\n");
m.append("\n");
m.append("If you have received this mail in error,"
+ " you do not need to take any\n");
m.append("action to cancel the account."
+ " The account will not be activated, and\n");
m.append("you will not receive any further emails.\n");
m.append("\n");
m.append("If clicking the link above does not work,"
+ " copy and paste the URL in a\n");
m.append("new browser window instead.\n");
m.append("\n");
m.append("This is a send-only email address."
+ " Replies to this message will not\n");
m.append("be read or answered.\n");
final javax.mail.Session out = server.getOutgoingMail();
if (out == null) {
cb.onFailure(new IllegalStateException("Outgoing mail is disabled"));
return;
}
try {
final MimeMessage msg = new MimeMessage(out);
msg.setFrom(new InternetAddress(gi.getEmailAddress(), gi.getName()));
msg.setRecipients(Message.RecipientType.TO, address);
msg.setSubject("[Gerrit Code Review] Email Verification");
msg.setSentDate(new Date());
msg.setText(m.toString());
Transport.send(msg);
cb.onSuccess(VoidResult.INSTANCE);
} catch (MessagingException e) {
cb.onFailure(e);
} catch (UnsupportedEncodingException e) {
cb.onFailure(e);
}
}
public void validateEmail(final String token,
final AsyncCallback<VoidResult> callback) {
final String address;
try {
final ValidToken t =
server.getEmailRegistrationToken().checkToken(token, null);
if (t == null || t.getData() == null || "".equals(t.getData())) {
callback.onFailure(new IllegalStateException("Invalid token"));
return;
}
address = new String(Base64.decode(t.getData()), "UTF-8");
if (!address.contains("@")) {
callback.onFailure(new IllegalStateException("Invalid token"));
return;
}
} catch (XsrfException e) {
callback.onFailure(e);
return;
} catch (UnsupportedEncodingException e) {
callback.onFailure(e);
return;
}
run(callback, new Action<VoidResult>() {
public VoidResult run(ReviewDb db) throws OrmException {
final Account.Id me = Common.getAccountId();
final List<AccountExternalId> exists =
db.accountExternalIds().byAccountEmail(me, address).toList();
if (!exists.isEmpty()) {
return VoidResult.INSTANCE;
}
try {
final AccountExternalId id =
new AccountExternalId(new AccountExternalId.Key(me, "mailto:"
+ address));
id.setEmailAddress(address);
db.accountExternalIds().insert(Collections.singleton(id));
} catch (OrmDuplicateKeyException e) {
// Ignore a duplicate registration
}
final Account a = db.accounts().get(me);
a.setPreferredEmail(address);
db.accounts().update(Collections.singleton(a));
Common.getAccountCache().invalidate(me);
return VoidResult.INSTANCE;
}
});
}
}

View File

@@ -19,6 +19,6 @@ package com.google.gerrit.server;
public class AccountSecuritySrv extends GerritJsonServlet {
@Override
protected Object createServiceHandle() throws Exception {
return new AccountSecurityImpl();
return new AccountSecurityImpl(GerritServer.getInstance());
}
}

View File

@@ -89,6 +89,7 @@ public class GerritServer {
private final PersonIdent gerritPersonIdentTemplate;
private final SignedToken xsrf;
private final SignedToken account;
private final SignedToken emailReg;
private final RepositoryCache repositories;
private final javax.mail.Session outgoingMail;
@@ -112,6 +113,7 @@ public class GerritServer {
break;
}
account = new SignedToken(accountCookieAge, sConfig.accountPrivateKey);
emailReg = new SignedToken(5 * 24 * 60 * 60, sConfig.accountPrivateKey);
if (sConfig.gitBasePath != null) {
repositories = new RepositoryCache(new File(sConfig.gitBasePath));
@@ -129,7 +131,7 @@ public class GerritServer {
}
gerritPersonIdentTemplate = new PersonIdent(sConfig.gerritGitName, email);
outgoingMail = createOutgoingMail();
Common.setSchemaFactory(db);
Common.setProjectCache(new ProjectCache());
Common.setAccountCache(new AccountCache());
@@ -399,6 +401,11 @@ public class GerritServer {
return account;
}
/** Get the signature used for email registration/validation links. */
public SignedToken getEmailRegistrationToken() {
return emailReg;
}
public String getLoginHttpHeader() {
return sConfig.loginHttpHeader;
}