diff --git a/doc/sphinx-guides/source/installation/administration.rst b/doc/sphinx-guides/source/installation/administration.rst index 6c665cbb650..59cf11652a3 100644 --- a/doc/sphinx-guides/source/installation/administration.rst +++ b/doc/sphinx-guides/source/installation/administration.rst @@ -72,6 +72,17 @@ User Administration There isn't much in the way of user administration tools built in to Dataverse. +Confirm Email ++++++++++++++ + +Dataverse encourages builtin/local users to verify their email address upon signup or email change so that sysadmins can be assured that users can be contacted. + +The app will send a standard welcome email with a URL the user can click, which, when activated, will store a ``lastconfirmed`` timestamp in the ``authenticateduser`` table of the database. Any time this is "null" for a user (immediately after signup and/or changing of their Dataverse email address), their current email on file is considered to not be verified. The link that is sent expires after a time (the default is 24 hours), but this is configurable by a superuser via the ``:MinutesUntilConfirmEmailTokenExpires`` config option. + +Should users' URL token expire, they will see a "Verify Email" button on the account information page to send another URL. + +Sysadmins can determine which users have verified their email addresses by looking for the presence of the value ``emailLastConfirmed`` in the JSON output from listing users (see the "Admin" section of the :doc:`/api/native-api`). The email addresses for Shibboleth users are re-confirmed on every login. + Deleting an API Token +++++++++++++++++++++ diff --git a/doc/sphinx-guides/source/installation/config.rst b/doc/sphinx-guides/source/installation/config.rst index 142e69ff3e3..347a86e92db 100644 --- a/doc/sphinx-guides/source/installation/config.rst +++ b/doc/sphinx-guides/source/installation/config.rst @@ -445,6 +445,11 @@ Set ``SearchHighlightFragmentSize`` to override the default value of 100 from ht Allow for migration of non-conformant data (especially dates) from DVN 3.x to Dataverse 4. +:MinutesUntilConfirmEmailTokenExpires ++++++++++++++++++++++++++++++++++++++ + +The duration in minutes before "Confirm Email" URLs expire. The default is 1440 minutes (24 hours). See also :doc:`/installation/administration`. + :ShibEnabled ++++++++++++ diff --git a/scripts/database/upgrades/upgrade_v4.5_to_v4.5.1.sql b/scripts/database/upgrades/upgrade_v4.5_to_v4.5.1.sql new file mode 100644 index 00000000000..6296fca8a5f --- /dev/null +++ b/scripts/database/upgrades/upgrade_v4.5_to_v4.5.1.sql @@ -0,0 +1 @@ +ALTER TABLE authenticateduser ADD COLUMN emailconfirmed timestamp without time zone; diff --git a/src/main/java/Bundle.properties b/src/main/java/Bundle.properties index bc39855adc6..42db3de2162 100755 --- a/src/main/java/Bundle.properties +++ b/src/main/java/Bundle.properties @@ -138,7 +138,8 @@ wasPublished=, was published in wasReturnedByReviewer=, was returned by the curator of toReview=Don't forget to publish it or send it back to the contributor! worldMap.added=dataset had a WorldMap layer data added to it. -notification.welcome=Welcome to {0} Dataverse! Get started by adding or finding data. Have questions? Check out the {1}. Want to test out Dataverse features? Use our {2}. +# Bundle file editors, please note that "notification.welcome" is used in a unit test. +notification.welcome=Welcome to {0} Dataverse! Get started by adding or finding data. Have questions? Check out the {1}. Want to test out Dataverse features? Use our {2}. Also, check for your welcome email to verify your address. notification.demoSite=Demo Site notification.requestFileAccess=File access requested for dataset: {0}. notification.grantFileAccess=Access granted for files in dataset: {0}. @@ -204,6 +205,16 @@ login.builtin.invalidUsernameEmailOrPassword=The username, email address, or pas # how do we exercise login.error? Via a password upgrade failure? See https://github.com/IQSS/dataverse/pull/2922 login.error=Error validating the username, email address, or password. Please try again. If the problem persists, contact an administrator. +#confirmemail.xhtml +confirmEmail.pageTitle=Email Verification +confirmEmail.submitRequest=Verify Email +confirmEmail.submitRequest.success=A verification email has been sent to {0}. Note, the verify link will expire after {1}. +confirmEmail.details.success=Email address verified! +confirmEmail.details.failure=We were unable to verify your email address. Please navigate to your Account Information page and click the "Verify Email" button. +confirmEmail.details.goToAccountPageButton=Go to Account Information +confirmEmail.notVerified=Not Verified +confirmEmail.verified=Verified + #shib.xhtml shib.btn.convertAccount=Convert Account shib.btn.createAccount=Create Account @@ -420,9 +431,12 @@ notification.email.returned.dataset.subject=Dataverse: Your dataset has been ret notification.email.create.account.subject=Dataverse: Your account has been created notification.email.assign.role.subject=Dataverse: You have been assigned a role notification.email.revoke.role.subject=Dataverse: Your role has been revoked +notification.email.verifyEmail.subject=Dataverse: Verify your email address notification.email.greeting=Hello, \n +# Bundle file editors, please note that "notification.email.welcome" is used in a unit test notification.email.welcome=Welcome to Dataverse! Get started by adding or finding data. Have questions? Check out the User Guide at {0}/{1}/user/ or contact Dataverse Support for assistance. Want to test out Dataverse features? Use our Demo Site at https://demo.dataverse.org +notification.email.welcomeConfirmEmailAddOn=\n\nPlease verify your email address at {0}. Note, the verify link will expire after {1}. Send another verification email by visiting your account page. notification.email.requestFileAccess=File access requested for dataset: {0}. Manage permissions at {1}. notification.email.grantFileAccess=Access granted for files in dataset: {0} (view at {1}). notification.email.rejectFileAccess=Access rejected for requested files in dataset: {0} (view at {1}). @@ -437,6 +451,11 @@ notification.email.worldMap.added={0} (view at {1}) had WorldMap layer data adde notification.email.closing=\n\nThank you,\nThe Dataverse Project notification.email.assignRole=You are now {0} for the {1} "{2}" (view at {3}). notification.email.revokeRole=One of your roles for the {0} "{1}" has been revoked (view at {2}). +notification.email.changeEmail=Hello, {0}.{1}\n\nPlease contact us if you did not intend this change or if you need assistance. +hours=hours +hour=hour +minutes=minutes +minute=minute # passwordreset.xhtml diff --git a/src/main/java/edu/harvard/iq/dataverse/MailServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/MailServiceBean.java index f82b7a3e666..0893909a6c0 100644 --- a/src/main/java/edu/harvard/iq/dataverse/MailServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/MailServiceBean.java @@ -9,6 +9,7 @@ import edu.harvard.iq.dataverse.authorization.groups.Group; import edu.harvard.iq.dataverse.authorization.groups.GroupServiceBean; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.confirmemail.ConfirmEmailServiceBean; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import edu.harvard.iq.dataverse.settings.SettingsServiceBean.Key; import edu.harvard.iq.dataverse.util.BundleUtil; @@ -62,6 +63,8 @@ public class MailServiceBean implements java.io.Serializable { PermissionServiceBean permissionService; @EJB GroupServiceBean groupService; + @EJB + ConfirmEmailServiceBean confirmEmailService; private static final Logger logger = Logger.getLogger(MailServiceBean.class.getCanonicalName()); @@ -426,6 +429,9 @@ private String getMessageTextBasedOnNotification(UserNotification userNotificati systemConfig.getGuidesBaseUrl(), systemConfig.getVersion() )); + String optionalConfirmEmailAddon = confirmEmailService.optionalConfirmEmailAddonMsg(userNotification.getUser()); + accountCreatedMessage += optionalConfirmEmailAddon; + logger.info("accountCreatedMessage: " + accountCreatedMessage); return messageText += accountCreatedMessage; } diff --git a/src/main/java/edu/harvard/iq/dataverse/Shib.java b/src/main/java/edu/harvard/iq/dataverse/Shib.java index 3046a90fbbf..0fbf2929dfa 100644 --- a/src/main/java/edu/harvard/iq/dataverse/Shib.java +++ b/src/main/java/edu/harvard/iq/dataverse/Shib.java @@ -295,6 +295,10 @@ public String confirmAndCreateAccount() { if (au != null) { logger.fine("created user " + au.getIdentifier()); logInUserAndSetShibAttributes(au); + /** + * @todo Move this to + * AuthenticationServiceBean.createAuthenticatedUser + */ userNotificationService.sendNotification(au, new Timestamp(new Date().getTime()), UserNotification.Type.CREATEACC, null); diff --git a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java index 2b2cb6f03fd..a796846cb95 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/AbstractApiBean.java @@ -13,6 +13,7 @@ import edu.harvard.iq.dataverse.MetadataBlockServiceBean; import edu.harvard.iq.dataverse.PermissionServiceBean; import edu.harvard.iq.dataverse.RoleAssigneeServiceBean; +import edu.harvard.iq.dataverse.UserNotificationServiceBean; import edu.harvard.iq.dataverse.UserServiceBean; import edu.harvard.iq.dataverse.actionlogging.ActionLogServiceBean; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; @@ -22,6 +23,7 @@ import edu.harvard.iq.dataverse.authorization.users.GuestUser; import edu.harvard.iq.dataverse.authorization.users.PrivateUrlUser; import edu.harvard.iq.dataverse.authorization.users.User; +import edu.harvard.iq.dataverse.confirmemail.ConfirmEmailServiceBean; import edu.harvard.iq.dataverse.engine.command.Command; import edu.harvard.iq.dataverse.engine.command.DataverseRequest; import edu.harvard.iq.dataverse.engine.command.exception.CommandException; @@ -175,6 +177,12 @@ String getWrappedMessageWhenJson() { @EJB protected PrivateUrlServiceBean privateUrlSvc; + @EJB + protected ConfirmEmailServiceBean confirmEmailSvc; + + @EJB + protected UserNotificationServiceBean userNotificationSvc; + @PersistenceContext(unitName = "VDCNet-ejbPU") protected EntityManager em; @@ -480,4 +488,4 @@ public T get() { public T get() { return ref.get(); } -} \ No newline at end of file +} diff --git a/src/main/java/edu/harvard/iq/dataverse/api/Admin.java b/src/main/java/edu/harvard/iq/dataverse/api/Admin.java index 0026ec85a6b..5ef20c36158 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/Admin.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/Admin.java @@ -18,6 +18,9 @@ import edu.harvard.iq.dataverse.authorization.providers.shib.ShibServiceBean; import edu.harvard.iq.dataverse.authorization.providers.shib.ShibUtil; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.confirmemail.ConfirmEmailData; +import edu.harvard.iq.dataverse.confirmemail.ConfirmEmailException; +import edu.harvard.iq.dataverse.confirmemail.ConfirmEmailInitResponse; import edu.harvard.iq.dataverse.engine.command.impl.PublishDataverseCommand; import edu.harvard.iq.dataverse.settings.Setting; import javax.json.Json; @@ -34,6 +37,7 @@ import static edu.harvard.iq.dataverse.util.json.NullSafeJsonBuilder.jsonObjectBuilder; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.*; import java.io.StringReader; +import java.util.List; import java.util.Map; import java.util.logging.Level; import java.util.logging.Logger; @@ -528,6 +532,50 @@ public Response validate() { return okResponse(msg); } + /** + * This method is used in integration tests. + * + * @param userId The database id of an AuthenticatedUser. + * @return The confirm email token. + */ + @Path("confirmEmail/{userId}") + @GET + public Response getConfirmEmailToken(@PathParam("userId") long userId) { + AuthenticatedUser user = authSvc.findByID(userId); + if (user != null) { + ConfirmEmailData confirmEmailData = confirmEmailSvc.findSingleConfirmEmailDataByUser(user); + if (confirmEmailData != null) { + return okResponse(Json.createObjectBuilder().add("token", confirmEmailData.getToken())); + } + } + return errorResponse(Status.BAD_REQUEST, "Could not find confirm email token for user " + userId); + } + + /** + * This method is used in integration tests. + * + * @param userId The database id of an AuthenticatedUser. + */ + @Path("confirmEmail/{userId}") + @POST + public Response startConfirmEmailProcess(@PathParam("userId") long userId) { + AuthenticatedUser user = authSvc.findByID(userId); + if (user != null) { + try { + ConfirmEmailInitResponse confirmEmailInitResponse = confirmEmailSvc.beginConfirm(user); + ConfirmEmailData confirmEmailData = confirmEmailInitResponse.getConfirmEmailData(); + return okResponse( + Json.createObjectBuilder() + .add("tokenCreated", confirmEmailData.getCreated().toString()) + .add("identifier", user.getUserIdentifier() + )); + } catch (ConfirmEmailException ex) { + return errorResponse(Status.BAD_REQUEST, "Could not start confirm email process for user " + userId + ": " + ex.getLocalizedMessage()); + } + } + return errorResponse(Status.BAD_REQUEST, "Could not find user based on " + userId); + } + /** * This method is used by an integration test in UsersIT.java to exercise * bug https://github.com/IQSS/dataverse/issues/3287 . Not for use by users! @@ -538,10 +586,11 @@ public Response convertUserFromBcryptToSha1(String json) { JsonReader jsonReader = Json.createReader(new StringReader(json)); JsonObject object = jsonReader.readObject(); jsonReader.close(); - BuiltinUser builtinUser = builtinUserService.find(new Long(object.getInt("builtinUserId"))); + BuiltinUser builtinUser = builtinUserService.find(new Long(object.getInt("builtinUserId"))); builtinUser.updateEncryptedPassword("4G7xxL9z11/JKN4jHPn4g9iIQck=", 0); // password is "sha-1Pass", 0 means SHA-1 BuiltinUser savedUser = builtinUserService.save(builtinUser); return okResponse("foo: " + savedUser); + } } diff --git a/src/main/java/edu/harvard/iq/dataverse/api/BuiltinUsers.java b/src/main/java/edu/harvard/iq/dataverse/api/BuiltinUsers.java index 1460e11f6be..c5a6f3a9406 100644 --- a/src/main/java/edu/harvard/iq/dataverse/api/BuiltinUsers.java +++ b/src/main/java/edu/harvard/iq/dataverse/api/BuiltinUsers.java @@ -1,5 +1,6 @@ package edu.harvard.iq.dataverse.api; +import edu.harvard.iq.dataverse.UserNotification; import edu.harvard.iq.dataverse.actionlogging.ActionLogRecord; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinAuthenticationProvider; @@ -8,7 +9,6 @@ import edu.harvard.iq.dataverse.authorization.providers.builtin.PasswordEncryption; import edu.harvard.iq.dataverse.authorization.users.ApiToken; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; -import edu.harvard.iq.dataverse.util.json.JsonPrinter; import java.sql.Timestamp; import java.util.Calendar; import java.util.logging.Level; @@ -26,6 +26,7 @@ import javax.ws.rs.core.Response.Status; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.json; import static edu.harvard.iq.dataverse.util.json.JsonPrinter.jsonForAuthUser; +import java.util.Date; /** * REST API bean for managing {@link BuiltinUser}s. @@ -123,6 +124,15 @@ private Response internalSave(BuiltinUser user, String password, String key) { user.getUserName(), user.getDisplayInfo(), false); + + /** + * @todo Move this to + * AuthenticationServiceBean.createAuthenticatedUser + */ + userNotificationSvc.sendNotification(au, + new Timestamp(new Date().getTime()), + UserNotification.Type.CREATEACC, null); + ApiToken token = new ApiToken(); token.setTokenString(java.util.UUID.randomUUID().toString()); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java index af826e622d9..c716129dc29 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/AuthenticationServiceBean.java @@ -18,6 +18,8 @@ import edu.harvard.iq.dataverse.authorization.providers.shib.ShibAuthenticationProvider; import edu.harvard.iq.dataverse.authorization.users.ApiToken; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.confirmemail.ConfirmEmailData; +import edu.harvard.iq.dataverse.confirmemail.ConfirmEmailServiceBean; import edu.harvard.iq.dataverse.passwordreset.PasswordResetData; import edu.harvard.iq.dataverse.passwordreset.PasswordResetServiceBean; import java.sql.SQLException; @@ -74,6 +76,9 @@ public class AuthenticationServiceBean { @EJB UserNotificationServiceBean userNotificationService; + @EJB + ConfirmEmailServiceBean confirmEmailService; + @EJB PasswordResetServiceBean passwordResetServiceBean; @@ -214,17 +219,23 @@ public void removeApiToken(AuthenticatedUser user){ * assignments, etc. * * Longer term, the intention is to have a "disableAuthenticatedUser" - * method/command. + * method/command. See https://github.com/IQSS/dataverse/issues/2419 */ public void deleteAuthenticatedUser(Object pk) { AuthenticatedUser user = em.find(AuthenticatedUser.class, pk); - - - if (user!=null) { + + if (user != null) { ApiToken apiToken = findApiTokenByUser(user); if (apiToken != null) { em.remove(apiToken); } + ConfirmEmailData confirmEmailData = confirmEmailService.findSingleConfirmEmailDataByUser(user); + if (confirmEmailData != null) { + /** + * @todo This could probably be a cascade delete instead. + */ + em.remove(confirmEmailData); + } for (UserNotification notification : userNotificationService.findByUser(user.getId())) { userNotificationService.delete(notification); } @@ -445,7 +456,20 @@ public AuthenticatedUser createAuthenticatedUser(UserRecordIdentifier userRecord AuthenticatedUserLookup auusLookup = userRecordId.createAuthenticatedUserLookup(authenticatedUser); em.persist( auusLookup ); authenticatedUser.setAuthenticatedUserLookup(auusLookup); - + + if (ShibAuthenticationProvider.PROVIDER_ID.equals(auusLookup.getAuthenticationProviderId())) { + Timestamp emailConfirmedNow = new Timestamp(new Date().getTime()); + // Email addresses for Shib users are confirmed by the Identity Provider. + authenticatedUser.setEmailConfirmed(emailConfirmedNow); + authenticatedUser = save(authenticatedUser); + } else { + /** + * @todo Rather than creating a token directly here it might be + * better to do something like "startConfirmEmailProcessForNewUser". + */ + confirmEmailService.createToken(authenticatedUser); + } + actionLogSvc.log( new ActionLogRecord(ActionLogRecord.ActionType.Auth, "createUser") .setInfo(authenticatedUser.getIdentifier())); diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/builtin/BuiltinUserPage.java b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/builtin/BuiltinUserPage.java index 1093b39cdbd..6f684d07d83 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/providers/builtin/BuiltinUserPage.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/providers/builtin/BuiltinUserPage.java @@ -27,16 +27,23 @@ import edu.harvard.iq.dataverse.authorization.groups.Group; import edu.harvard.iq.dataverse.authorization.groups.GroupServiceBean; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.confirmemail.ConfirmEmailData; +import edu.harvard.iq.dataverse.confirmemail.ConfirmEmailException; +import edu.harvard.iq.dataverse.confirmemail.ConfirmEmailInitResponse; +import edu.harvard.iq.dataverse.confirmemail.ConfirmEmailServiceBean; +import edu.harvard.iq.dataverse.confirmemail.ConfirmEmailUtil; import edu.harvard.iq.dataverse.mydata.MyDataPage; import edu.harvard.iq.dataverse.passwordreset.PasswordValidator; import edu.harvard.iq.dataverse.settings.SettingsServiceBean; import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.JsfHelper; import static edu.harvard.iq.dataverse.util.JsfHelper.JH; +import edu.harvard.iq.dataverse.util.SystemConfig; import java.io.UnsupportedEncodingException; import java.net.URLDecoder; import java.sql.Timestamp; import java.util.ArrayList; +import java.util.Arrays; import java.util.Date; import java.util.List; import java.util.Set; @@ -89,6 +96,10 @@ public enum EditMode { @EJB AuthenticationServiceBean authenticationService; @EJB + ConfirmEmailServiceBean confirmEmailService; + @EJB + SystemConfig systemConfig; + @EJB GroupServiceBean groupService; @Inject SettingsWrapper settingsWrapper; @@ -407,7 +418,8 @@ public void validateNewPassword(FacesContext context, UIComponent toValidate, Ob context.addMessage(toValidate.getClientId(context), message); } } - + + public void updatePassword(String userName) { String plainTextPassword = PasswordEncryption.generateRandomPassword(); @@ -421,6 +433,7 @@ public void updatePassword(String userName) { public String save() { boolean passwordChanged = false; + boolean emailChanged = false; if (editMode == EditMode.CREATE || editMode == EditMode.CHANGE_PASSWORD) { if (inputPassword != null) { builtinUser.updateEncryptedPassword(PasswordEncryption.get().encrypt(inputPassword), PasswordEncryption.getLatestVersionNumber()); @@ -472,13 +485,32 @@ public String save() { } else { - authSvc.updateAuthenticatedUser(currentUser, builtinUser.getDisplayInfo()); + String emailBeforeUpdate = currentUser.getEmail(); + AuthenticatedUser savedUser = authSvc.updateAuthenticatedUser(currentUser, builtinUser.getDisplayInfo()); + String emailAfterUpdate = savedUser.getEmail(); + if (!emailBeforeUpdate.equals(emailAfterUpdate)) { + emailChanged = true; + } editMode = null; String msg = "Your account information has been successfully updated."; if (passwordChanged) { msg = "Your account password has been successfully changed."; } - JsfHelper.addFlashMessage(msg); + if (emailChanged) { + ConfirmEmailUtil confirmEmailUtil = new ConfirmEmailUtil(); + String expTime = confirmEmailUtil.friendlyExpirationTime(systemConfig.getMinutesUntilConfirmEmailTokenExpires()); + msg = msg + " Your email address has changed and must be re-verified. Please check your inbox at " + currentUser.getEmail() + " and follow the link we've sent. \n\nAlso, please note that the link will only work for the next " + expTime + " before it has expired."; + boolean sendEmail = true; + try { + ConfirmEmailInitResponse confirmEmailInitResponse = confirmEmailService.beginConfirm(currentUser); + } catch (ConfirmEmailException ex) { + logger.info("Unable to send email confirmation link to user id " + savedUser.getId()); + } + session.setUser(currentUser); + JsfHelper.addSuccessMessage(msg); + } else { + JsfHelper.addFlashMessage(msg); + } return null; } } @@ -592,4 +624,51 @@ public void displayNotification() { } } } + + public void sendConfirmEmail() { + logger.fine("called sendConfirmEmail()"); + String userEmail = currentUser.getEmail(); + ConfirmEmailUtil confirmEmailUtil = new ConfirmEmailUtil(); + + try { + confirmEmailService.beginConfirm(currentUser); + List args = Arrays.asList( + userEmail, + confirmEmailUtil.friendlyExpirationTime(systemConfig.getMinutesUntilConfirmEmailTokenExpires())); + JsfHelper.addSuccessMessage(BundleUtil.getStringFromBundle("confirmEmail.submitRequest.success", args)); + } catch (ConfirmEmailException ex) { + Logger.getLogger(BuiltinUserPage.class.getName()).log(Level.SEVERE, null, ex); + } + } + + public boolean showVerifyEmailButton() { + /** + * Determines whether the button to send a verification email appears on user page + */ + if (confirmEmailService.findSingleConfirmEmailDataByUser(currentUser) == null + && currentUser.getEmailConfirmed() == null) { + return true; + } + return false; + } + + public boolean isEmailIsVerified() { + if (currentUser.getEmailConfirmed() != null && confirmEmailService.findSingleConfirmEmailDataByUser(currentUser) == null) { + return true; + } else return false; + } + + public boolean isEmailNotVerified() { + if (currentUser.getEmailConfirmed() == null || confirmEmailService.findSingleConfirmEmailDataByUser(currentUser) != null) { + return true; + } else return false; + } + + public boolean isEmailGrandfathered() { + ConfirmEmailUtil confirmEmailUtil = new ConfirmEmailUtil(); + if (currentUser.getEmailConfirmed() == confirmEmailUtil.getGrandfatheredTime()) { + return true; + } else return false; + } + } diff --git a/src/main/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUser.java b/src/main/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUser.java index c8b07966f4e..b239cdc2cec 100644 --- a/src/main/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUser.java +++ b/src/main/java/edu/harvard/iq/dataverse/authorization/users/AuthenticatedUser.java @@ -74,6 +74,8 @@ public class AuthenticatedUser implements User, Serializable { private String position; private String lastName; private String firstName; + @Column(nullable = true) + private Timestamp emailConfirmed; private boolean superuser; /** @@ -122,7 +124,6 @@ public void applyDisplayInfo( AuthenticatedUserDisplayInfo inf ) { setEmail(inf.getEmailAddress()); setAffiliation( inf.getAffiliation() ); setPosition( inf.getPosition()); - } @Override @@ -188,6 +189,14 @@ public void setFirstName(String firstName) { this.firstName = firstName; } + public Timestamp getEmailConfirmed() { + return emailConfirmed; + } + + public void setEmailConfirmed(Timestamp emailConfirmed) { + this.emailConfirmed = emailConfirmed; + } + @Override public boolean isSuperuser() { return superuser; diff --git a/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailAttemptResponse.java b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailAttemptResponse.java new file mode 100644 index 00000000000..c1d60c101b4 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailAttemptResponse.java @@ -0,0 +1,31 @@ +package edu.harvard.iq.dataverse.confirmemail; + +/** + * + * @author bsilverstein + */ +public class ConfirmEmailAttemptResponse { + + private final boolean confirmed; + private final String messageSummary; + private final String messageDetail; + + public ConfirmEmailAttemptResponse(boolean confirmed, String messageSummary, String messageDetail) { + this.confirmed = confirmed; + this.messageSummary = messageSummary; + this.messageDetail = messageDetail; + } + + public boolean isConfirmed() { + return confirmed; + } + + public String getMessageSummary() { + return messageSummary; + } + + public String getMessageDetail() { + return messageDetail; + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailData.java b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailData.java new file mode 100644 index 00000000000..c751b62a3a0 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailData.java @@ -0,0 +1,107 @@ +package edu.harvard.iq.dataverse.confirmemail; + +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import java.io.Serializable; +import java.sql.Timestamp; +import java.util.Date; +import java.util.UUID; +import javax.persistence.Column; +import javax.persistence.Entity; +import javax.persistence.GeneratedValue; +import javax.persistence.GenerationType; +import javax.persistence.Id; +import javax.persistence.Index; +import javax.persistence.JoinColumn; +import javax.persistence.NamedQueries; +import javax.persistence.NamedQuery; +import javax.persistence.OneToOne; +import javax.persistence.Table; + +/** + * + * @author bsilverstein + */ +@Table(indexes = { + @Index(columnList = "token"), + @Index(columnList = "authenticateduser_id")}) +@NamedQueries({ + @NamedQuery(name = "ConfirmEmailData.findAll", + query = "SELECT prd FROM ConfirmEmailData prd"), + @NamedQuery(name = "ConfirmEmailData.findByUser", + query = "SELECT prd FROM ConfirmEmailData prd WHERE prd.authenticatedUser = :user"), + @NamedQuery(name = "ConfirmEmailData.findByToken", + query = "SELECT prd FROM ConfirmEmailData prd WHERE prd.token = :token") +}) +@Entity +public class ConfirmEmailData implements Serializable { + + @Id + @GeneratedValue(strategy = GenerationType.IDENTITY) + private Long id; + + @Column(nullable = true) + private String token; + + @OneToOne + @JoinColumn(nullable = false, unique = true) + private AuthenticatedUser authenticatedUser; + + @Column(nullable = false) + private Timestamp created; + + @Column(nullable = false) + private Timestamp expires; + + public ConfirmEmailData(AuthenticatedUser anAuthenticatedUser, long minutesUntilConfirmEmailTokenExpires) { + authenticatedUser = anAuthenticatedUser; + token = UUID.randomUUID().toString(); + long nowInMilliseconds = new Date().getTime(); + created = new Timestamp(nowInMilliseconds); + long ONE_MINUTE_IN_MILLISECONDS = 60000; + long futureInMilliseconds = nowInMilliseconds + (minutesUntilConfirmEmailTokenExpires * ONE_MINUTE_IN_MILLISECONDS); + expires = new Timestamp(new Date(futureInMilliseconds).getTime()); + } + + public boolean isExpired() { + if (this.expires == null) { + return true; + } + long expiresInMilliseconds = this.expires.getTime(); + long nowInMilliseconds = new Date().getTime(); + return nowInMilliseconds > expiresInMilliseconds; + } + + public String getToken() { + return token; + } + + public AuthenticatedUser getAuthenticatedUser() { + return authenticatedUser; + } + + public Timestamp getCreated() { + return created; + } + + public Timestamp getExpires() { + return expires; + } + + public Long getId() { + return id; + } + + public void setId(Long id) { + this.id = id; + } + + /** + * This is only here because it has to be: "The class should have a no-arg, + * public or protected constructor." Please use the constructor that takes + * arguments. + */ + @Deprecated + public ConfirmEmailData() { + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailException.java b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailException.java new file mode 100644 index 00000000000..add7cd922fb --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailException.java @@ -0,0 +1,17 @@ +package edu.harvard.iq.dataverse.confirmemail; + +/** + * + * @author bsilverstein + */ +public class ConfirmEmailException extends Exception { + + public ConfirmEmailException(String message) { + super(message); + } + + public ConfirmEmailException(String message, Throwable cause) { + super(message, cause); + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailExecResponse.java b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailExecResponse.java new file mode 100644 index 00000000000..aaeb1b90e79 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailExecResponse.java @@ -0,0 +1,29 @@ +package edu.harvard.iq.dataverse.confirmemail; + +/** + * + * @author bsilverstein + */ +public class ConfirmEmailExecResponse { + + private String tokenQueried; + private ConfirmEmailData confirmEmailData; + + public ConfirmEmailExecResponse(String tokenQueried, ConfirmEmailData confirmEmailData) { + this.tokenQueried = tokenQueried; + this.confirmEmailData = confirmEmailData; + } + + public String getTokenQueried() { + return tokenQueried; + } + + public ConfirmEmailData getConfirmEmailData() { + return confirmEmailData; + } + + public void setConfirmEmailData(ConfirmEmailData confirmEmailData) { + this.confirmEmailData = confirmEmailData; + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailInitResponse.java b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailInitResponse.java new file mode 100644 index 00000000000..87ed1c3a58f --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailInitResponse.java @@ -0,0 +1,34 @@ +package edu.harvard.iq.dataverse.confirmemail; + +/** + * + * @author bsilverstein + */ +public class ConfirmEmailInitResponse { + + private boolean userFound; + private String confirmUrl; + private ConfirmEmailData confirmEmailData; + + public ConfirmEmailInitResponse(boolean userFound) { + this.userFound = userFound; + } + + public ConfirmEmailInitResponse(boolean userFound, ConfirmEmailData confirmEmailData, String confirmUrl) { + this.userFound = userFound; + this.confirmEmailData = confirmEmailData; + this.confirmUrl = confirmUrl; + } + + public boolean isUserFound() { + return userFound; + } + + public String getConfirmUrl() { + return confirmUrl; + } + + public ConfirmEmailData getConfirmEmailData() { + return confirmEmailData; + } +} diff --git a/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailPage.java b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailPage.java new file mode 100644 index 00000000000..823d2c111f2 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailPage.java @@ -0,0 +1,111 @@ +package edu.harvard.iq.dataverse.confirmemail; + +import edu.harvard.iq.dataverse.DataverseSession; +import edu.harvard.iq.dataverse.actionlogging.ActionLogServiceBean; +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.util.BundleUtil; +import edu.harvard.iq.dataverse.util.JsfHelper; +import java.util.logging.Logger; +import javax.ejb.EJB; +import javax.faces.view.ViewScoped; +import javax.inject.Inject; +import javax.inject.Named; + +/** + * + * @author bsilverstein + */ +@ViewScoped +@Named("ConfirmEmailPage") +public class ConfirmEmailPage implements java.io.Serializable { + + private static final Logger logger = Logger.getLogger(ConfirmEmailPage.class.getCanonicalName()); + + @EJB + ConfirmEmailServiceBean confirmEmailService; + @Inject + DataverseSession session; + + @EJB + ActionLogServiceBean actionLogSvc; + + /** + * The unique string used to look up a user and continue the email + * confirmation. + */ + String token; + + /** + * The user looked up by the token who will be confirming their email. + */ + AuthenticatedUser user; + + /** + * The link that is emailed to the user to confirm the email that contains a + * token. + */ + String confirmEmailUrl; + + ConfirmEmailData confirmEmailData; + + public String init() { + if (token != null) { + ConfirmEmailExecResponse confirmEmailExecResponse = confirmEmailService.processToken(token); + confirmEmailData = confirmEmailExecResponse.getConfirmEmailData(); + if (confirmEmailData != null) { + user = confirmEmailData.getAuthenticatedUser(); + session.setUser(user); + JsfHelper.addSuccessMessage(BundleUtil.getStringFromBundle("confirmEmail.details.success")); + return "/dataverse.xhtml?faces-redirect=true"; + } + } + JsfHelper.addErrorMessage(BundleUtil.getStringFromBundle("confirmEmail.details.failure")); + /** + * @todo It would be nice to send a 404 response but if we enable this + * then the user sees the contents of 404.xhtml rather than the contents + * of JsfHelper.addErrorMessage above! + */ +// try { +// FacesContext.getCurrentInstance().getExternalContext().responseSendError(HttpServletResponse.SC_NOT_FOUND, null); +// } catch (IOException ex) { +// } + return null; + } + + public String getToken() { + return token; + } + + public void setToken(String token) { + this.token = token; + } + + public AuthenticatedUser getUser() { + return user; + } + + public String getConfirmEmailUrl() { + return confirmEmailUrl; + } + + public ConfirmEmailData getConfirmEmailData() { + return confirmEmailData; + } + + public void setConfirmEmailData(ConfirmEmailData confirmEmailData) { + this.confirmEmailData = confirmEmailData; + } + + public boolean isInvalidToken() { + if (confirmEmailData == null) { + return true; + } else { + return false; + } + } + + public String getRedirectToAccountInfoTab() { + return "/dataverseuser.xhtml?selectTab=accountInfo&faces-redirect=true"; + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailServiceBean.java new file mode 100644 index 00000000000..fe54dd79529 --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailServiceBean.java @@ -0,0 +1,232 @@ +package edu.harvard.iq.dataverse.confirmemail; + +import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; +import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; +import edu.harvard.iq.dataverse.MailServiceBean; +import edu.harvard.iq.dataverse.authorization.providers.shib.ShibAuthenticationProvider; +import edu.harvard.iq.dataverse.util.BundleUtil; +import edu.harvard.iq.dataverse.util.SystemConfig; +import java.sql.Timestamp; +import java.util.Arrays; +import java.util.Date; +import java.util.List; +import java.util.logging.Level; +import java.util.logging.Logger; +import javax.ejb.EJB; +import javax.ejb.Stateless; +import javax.persistence.EntityManager; +import javax.persistence.NoResultException; +import javax.persistence.NonUniqueResultException; +import javax.persistence.PersistenceContext; +import javax.persistence.TypedQuery; + +/** + * + * @author bsilverstein + */ +@Stateless +public class ConfirmEmailServiceBean { + + private static final Logger logger = Logger.getLogger(ConfirmEmailServiceBean.class.getCanonicalName()); + + @EJB + AuthenticationServiceBean dataverseUserService; + + @EJB + MailServiceBean mailService; + + @EJB + SystemConfig systemConfig; + + @PersistenceContext(unitName = "VDCNet-ejbPU") + private EntityManager em; + + /** + * Initiate the email confirmation process. + * + * @param user + * @return {@link ConfirmEmailInitResponse} + */ + public ConfirmEmailInitResponse beginConfirm(AuthenticatedUser user) throws ConfirmEmailException { + deleteAllExpiredTokens(); + if (user != null) { + return sendConfirm(user, true); + } else { + return new ConfirmEmailInitResponse(false); + } + } + + private ConfirmEmailInitResponse sendConfirm(AuthenticatedUser aUser, boolean sendEmail) throws ConfirmEmailException { + // delete old tokens for the user + ConfirmEmailData oldToken = findSingleConfirmEmailDataByUser(aUser); + if (oldToken != null) { + em.remove(oldToken); + } + + aUser.setEmailConfirmed(null); + aUser = em.merge(aUser); + // create a fresh token for the user iff they don't have an existing token + ConfirmEmailData confirmEmailData = new ConfirmEmailData(aUser, systemConfig.getMinutesUntilConfirmEmailTokenExpires()); + try { + /** + * @todo This "persist" is causing lots of noise in Glassfish's + * server.log if a token already exists (i.e. it isn't expired and + * wasn't deleted above). Exercise this bug by running + * ConfirmEmailIT. + */ + em.persist(confirmEmailData); + ConfirmEmailInitResponse confirmEmailInitResponse = new ConfirmEmailInitResponse(true, confirmEmailData, optionalConfirmEmailAddonMsg(aUser)); + if (sendEmail) { + sendLinkOnEmailChange(aUser, confirmEmailInitResponse.getConfirmUrl()); + } + + return confirmEmailInitResponse; + } catch (Exception ex) { + String msg = "Unable to save token for " + aUser.getEmail(); + throw new ConfirmEmailException(msg, ex); + } + + } + + /** + * @todo: We expect to send two messages. One at signup and another at email + * change. + */ + private void sendLinkOnEmailChange(AuthenticatedUser aUser, String confirmationUrl) throws ConfirmEmailException { + ConfirmEmailUtil confirmEmailUtil = new ConfirmEmailUtil(); + String messageBody = BundleUtil.getStringFromBundle("notification.email.changeEmail", Arrays.asList( + aUser.getFirstName(), + confirmationUrl, + confirmEmailUtil.friendlyExpirationTime(systemConfig.getMinutesUntilConfirmEmailTokenExpires()) + )); + logger.fine("messageBody:" + messageBody); + + try { + String toAddress = aUser.getEmail(); + /** + * @todo Move this to Bundle.properties. + */ + String subject = BundleUtil.getStringFromBundle("notification.email.verifyEmail.subject"); + mailService.sendSystemEmail(toAddress, subject, messageBody); + } catch (Exception ex) { + /** + * @todo get more specific about the exception that's thrown when + * `asadmin create-javamail-resource` (or equivalent) hasn't been + * run. + */ + throw new ConfirmEmailException("Problem sending email confirmation link possibily due to mail server not being configured."); + } + logger.log(Level.FINE, "attempted to send mail to {0}", aUser.getEmail()); + } + + /** + * Process the email confirmation token, allowing the user to confirm the + * email address or report on a invalid token. + * + * @param tokenQueried + */ + public ConfirmEmailExecResponse processToken(String tokenQueried) { + deleteAllExpiredTokens(); + ConfirmEmailExecResponse tokenUnusable = new ConfirmEmailExecResponse(tokenQueried, null); + ConfirmEmailData confirmEmailData = findSingleConfirmEmailDataByToken(tokenQueried); + if (confirmEmailData != null) { + if (confirmEmailData.isExpired()) { + // shouldn't reach here since tokens are being expired above + return tokenUnusable; + } else { + ConfirmEmailExecResponse goodTokenCanProceed = new ConfirmEmailExecResponse(tokenQueried, confirmEmailData); + if (confirmEmailData == null) { + logger.fine("Invalid token."); + return null; + } + long nowInMilliseconds = new Date().getTime(); + Timestamp emailConfirmed = new Timestamp(nowInMilliseconds); + AuthenticatedUser authenticatedUser = confirmEmailData.getAuthenticatedUser(); + authenticatedUser.setEmailConfirmed(emailConfirmed); + em.remove(confirmEmailData); + return goodTokenCanProceed; + } + } else { + return tokenUnusable; + } + } + + /** + * @param token + * @return Null or a single row of email confirmation data. + */ + private ConfirmEmailData findSingleConfirmEmailDataByToken(String token) { + ConfirmEmailData confirmEmailData = null; + TypedQuery typedQuery = em.createNamedQuery("ConfirmEmailData.findByToken", ConfirmEmailData.class); + typedQuery.setParameter("token", token); + try { + confirmEmailData = typedQuery.getSingleResult(); + } catch (NoResultException | NonUniqueResultException ex) { + logger.fine("When looking up " + token + " caught " + ex); + } + return confirmEmailData; + } + + public ConfirmEmailData findSingleConfirmEmailDataByUser(AuthenticatedUser user) { + ConfirmEmailData confirmEmailData = null; + TypedQuery typedQuery = em.createNamedQuery("ConfirmEmailData.findByUser", ConfirmEmailData.class); + typedQuery.setParameter("user", user); + try { + confirmEmailData = typedQuery.getSingleResult(); + } catch (NoResultException | NonUniqueResultException ex) { + logger.fine("When looking up user " + user + " caught " + ex); + } + return confirmEmailData; + } + + public List findAllConfirmEmailData() { + TypedQuery typedQuery = em.createNamedQuery("ConfirmEmailData.findAll", ConfirmEmailData.class); + List confirmEmailDatas = typedQuery.getResultList(); + return confirmEmailDatas; + } + + /** + * @return The number of tokens deleted. + */ + private long deleteAllExpiredTokens() { + long numDeleted = 0; + List allData = findAllConfirmEmailData(); + for (ConfirmEmailData data : allData) { + if (data.isExpired()) { + em.remove(data); + numDeleted++; + } + } + return numDeleted; + } + + public ConfirmEmailData createToken(AuthenticatedUser au) { + ConfirmEmailData confirmEmailData = new ConfirmEmailData(au, systemConfig.getMinutesUntilConfirmEmailTokenExpires()); + em.persist(confirmEmailData); + return confirmEmailData; + } + + public String optionalConfirmEmailAddonMsg(AuthenticatedUser user) { + ConfirmEmailUtil confirmEmailUtil = new ConfirmEmailUtil(); + final String emptyString = ""; + if (user == null) { + logger.info("Can't return confirm email message. AuthenticatedUser was null!"); + return emptyString; + } + if (ShibAuthenticationProvider.PROVIDER_ID.equals(user.getAuthenticatedUserLookup().getAuthenticationProviderId())) { + // Shib users don't have to confirm their email address. + return emptyString; + } + ConfirmEmailData confirmEmailData = findSingleConfirmEmailDataByUser(user); + if (confirmEmailData == null) { + logger.info("Can't return confirm email message. No ConfirmEmailData for user id " + user.getId()); + return emptyString; + } + String expTime = confirmEmailUtil.friendlyExpirationTime(systemConfig.getMinutesUntilConfirmEmailTokenExpires()); + String confirmEmailUrl = systemConfig.getDataverseSiteUrl() + "/confirmemail.xhtml?token=" + confirmEmailData.getToken(); + List args = Arrays.asList(confirmEmailUrl, expTime); + String optionalConfirmEmailMsg = BundleUtil.getStringFromBundle("notification.email.welcomeConfirmEmailAddOn", args); + return optionalConfirmEmailMsg; + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailUtil.java b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailUtil.java new file mode 100644 index 00000000000..d2f84b956df --- /dev/null +++ b/src/main/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailUtil.java @@ -0,0 +1,50 @@ +package edu.harvard.iq.dataverse.confirmemail; + +import edu.harvard.iq.dataverse.util.BundleUtil; +import java.sql.Timestamp; +import java.util.Date; + +public class ConfirmEmailUtil { + + public static Timestamp getGrandfatheredTime() { + /** + * Currently set to Y2K as an easter egg to easily set apart + * grandfathered accounts from post-launch accounts. + */ + Timestamp grandfatheredTime = Timestamp.valueOf("2000-01-01 00:00:00.0"); + return grandfatheredTime; + } + + public static String friendlyExpirationTime(int expirationInt) { + String measurement; + String expirationString; + long expirationLong = Long.valueOf(expirationInt); + boolean hasDecimal = false; + double expirationDouble = Double.valueOf(expirationLong); + + if (expirationLong == 1) { + measurement = BundleUtil.getStringFromBundle("minute"); + } else if (expirationLong < 60) { + measurement = BundleUtil.getStringFromBundle("minutes"); + } else if (expirationLong == 60) { + expirationLong = expirationLong / 60; + measurement = BundleUtil.getStringFromBundle("hour"); + } else { + if (expirationLong % 60 == 0) { + expirationLong = (long) (expirationLong / 60.0); + } else { + expirationDouble /= 60; + hasDecimal = true; + } + measurement = BundleUtil.getStringFromBundle("hours"); + } + if (hasDecimal == true) { + expirationString = String.valueOf(expirationDouble); + return expirationString + " " + measurement; + } else { + expirationString = String.valueOf(expirationLong); + return expirationString + " " + measurement; + } + } + +} diff --git a/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java b/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java index 65c3278eb8b..c4aedb81a71 100644 --- a/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java +++ b/src/main/java/edu/harvard/iq/dataverse/settings/SettingsServiceBean.java @@ -33,6 +33,7 @@ public class SettingsServiceBean { * So there. */ public enum Key { + MinutesUntilConfirmEmailTokenExpires, /** * Override Solr highlighting "fragsize" * https://wiki.apache.org/solr/HighlightingParameters#hl.fragsize diff --git a/src/main/java/edu/harvard/iq/dataverse/util/SystemConfig.java b/src/main/java/edu/harvard/iq/dataverse/util/SystemConfig.java index f03ae885e61..5246be6acd2 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/SystemConfig.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/SystemConfig.java @@ -201,9 +201,28 @@ public String getSolrHostColonPort() { String solrHostColonPort = settingsService.getValueForKey(SettingsServiceBean.Key.SolrHostColonPort, saneDefaultForSolrHostColonPort); return solrHostColonPort; } - - - + + public int getMinutesUntilConfirmEmailTokenExpires() { + final int minutesInOneDay = 1440; + final int reasonableDefault = minutesInOneDay; + SettingsServiceBean.Key key = SettingsServiceBean.Key.MinutesUntilConfirmEmailTokenExpires; + String valueFromDatabase = settingsService.getValueForKey(key); + if (valueFromDatabase != null) { + try { + int intFromDatabase = Integer.parseInt(valueFromDatabase); + if (intFromDatabase > 0) { + return intFromDatabase; + } else { + logger.info("Returning " + reasonableDefault + " for " + key + " because value must be greater than zero, not \"" + intFromDatabase + "\"."); + } + } catch (NumberFormatException ex) { + logger.info("Returning " + reasonableDefault + " for " + key + " because value must be an integer greater than zero, not \"" + valueFromDatabase + "\"."); + } + } + logger.fine("Returning " + reasonableDefault + " for " + key); + return reasonableDefault; + } + /** * The number of minutes for which a password reset token is valid. Can be * overridden by {@link #PASSWORD_RESET_TIMEOUT_IN_MINUTES}. diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java index f00bee0b140..1f8e4cc80cd 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/JsonPrinter.java @@ -91,6 +91,7 @@ public static JsonObjectBuilder jsonForAuthUser(AuthenticatedUser authenticatedU .add("affiliation", authenticatedUser.getAffiliation()) .add("position", authenticatedUser.getPosition()) .add("persistentUserId", authenticatedUser.getAuthenticatedUserLookup().getPersistentUserId()) + .add("emailLastConfirmed", authenticatedUser.getEmailConfirmed()) .add("authenticationProviderId", authenticatedUser.getAuthenticatedUserLookup().getAuthenticationProviderId()); } diff --git a/src/main/java/edu/harvard/iq/dataverse/util/json/NullSafeJsonBuilder.java b/src/main/java/edu/harvard/iq/dataverse/util/json/NullSafeJsonBuilder.java index 452a5366576..25c57b5cf7d 100644 --- a/src/main/java/edu/harvard/iq/dataverse/util/json/NullSafeJsonBuilder.java +++ b/src/main/java/edu/harvard/iq/dataverse/util/json/NullSafeJsonBuilder.java @@ -1,8 +1,10 @@ package edu.harvard.iq.dataverse.util.json; import edu.harvard.iq.dataverse.DatasetField; +import edu.harvard.iq.dataverse.api.Util; import java.math.BigDecimal; import java.math.BigInteger; +import java.sql.Timestamp; import javax.json.Json; import javax.json.JsonArrayBuilder; import javax.json.JsonObject; @@ -115,6 +117,8 @@ public NullSafeJsonBuilder addStrValue( String name, DatasetField field ) { public JsonObject build() { return delegate.build(); } - - + + public NullSafeJsonBuilder add(String name, Timestamp timestamp) { + return (timestamp != null) ? add(name, Util.getDateTimeFormat().format(timestamp)) : this; + } } diff --git a/src/main/webapp/confirmemail.xhtml b/src/main/webapp/confirmemail.xhtml new file mode 100644 index 00000000000..3b7a283382f --- /dev/null +++ b/src/main/webapp/confirmemail.xhtml @@ -0,0 +1,32 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/src/main/webapp/dataverseuser.xhtml b/src/main/webapp/dataverseuser.xhtml index a9ff9844789..30c726e2e45 100644 --- a/src/main/webapp/dataverseuser.xhtml +++ b/src/main/webapp/dataverseuser.xhtml @@ -20,7 +20,7 @@ - + @@ -31,10 +31,10 @@
@@ -58,7 +58,7 @@ - + @@ -74,46 +74,46 @@ - + - + - #{bundle['header.guides.user']} + #{bundle['header.guides.user']} - #{bundle['notification.demoSite']} - + #{bundle['notification.demoSite']} + - #{item.theObject.getDisplayName()} - + #{item.theObject.getDisplayName()} + - #{item.theObject.getOwner().getDisplayName()} + #{item.theObject.getOwner().getDisplayName()} - #{bundle['header.guides.user']} - + #{bundle['header.guides.user']} + - #{item.theObject.getDataset().getDisplayName()} + #{item.theObject.getDataset().getDisplayName()} #{item.theObject.getDataset().getOwner().getDisplayName()} - + #{bundle['header.guides.user']} - + @@ -135,7 +135,7 @@ #{item.theObject.getDataset().getOwner().getDisplayName()} - + @@ -146,7 +146,7 @@ #{item.theObject.getDataset().getOwner().getDisplayName()} - + @@ -171,14 +171,14 @@ #{item.theObject.displayName} - + #{item.theObject.getDataset().getDisplayName()} - + @@ -188,11 +188,11 @@ #{item.theObject.getDisplayName()} - + - + @@ -202,12 +202,12 @@ #{item.theObject.getDisplayName()} - + - + @@ -218,7 +218,7 @@ #{item.theObject.getOwner().getDisplayName()} - + @@ -229,7 +229,7 @@ #{item.theObject.getDisplayName()} - + @@ -238,7 +238,7 @@ #{item.theObject.getDisplayName()} - + @@ -247,7 +247,7 @@ #{item.theObject.getOwner().getDisplayName()} - + @@ -314,8 +314,25 @@ -
-

#{DataverseUserPage.currentUser.email}

+
+
+

#{DataverseUserPage.currentUser.email}

+
+
+

+ + Not Verified + + + Verified + +

+
+
+ + #{bundle['confirmEmail.submitRequest']} + +
@@ -378,8 +395,8 @@
- @@ -468,7 +485,7 @@
- diff --git a/src/test/java/edu/harvard/iq/dataverse/api/ConfirmEmailIT.java b/src/test/java/edu/harvard/iq/dataverse/api/ConfirmEmailIT.java new file mode 100644 index 00000000000..f3dc8b16f1d --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/api/ConfirmEmailIT.java @@ -0,0 +1,132 @@ +package edu.harvard.iq.dataverse.api; + +import com.jayway.restassured.RestAssured; +import static com.jayway.restassured.RestAssured.given; +import com.jayway.restassured.path.json.JsonPath; +import com.jayway.restassured.response.Response; +import java.util.logging.Logger; +import static junit.framework.Assert.assertEquals; +import org.junit.BeforeClass; +import org.junit.Test; +import static org.hamcrest.CoreMatchers.nullValue; +import static org.hamcrest.Matchers.startsWith; + +/** + * @author bsilverstein + */ +public class ConfirmEmailIT { + + private static final Logger logger = Logger.getLogger(ConfirmEmailIT.class.getCanonicalName()); + + @BeforeClass + public static void setUp() { + RestAssured.baseURI = UtilIT.getRestAssuredBaseUri(); + } + + @Test + public void testConfirm() { + + Response createUserToConfirm = UtilIT.createRandomUser(); + createUserToConfirm.prettyPrint(); + createUserToConfirm.then().assertThat() + .statusCode(200); + + long userIdToConfirm = JsonPath.from(createUserToConfirm.body().asString()).getLong("data.authenticatedUser.id"); + String userToConfirmApiToken = JsonPath.from(createUserToConfirm.body().asString()).getString("data.apiToken"); + String usernameToConfirm = JsonPath.from(createUserToConfirm.body().asString()).getString("data.user.userName"); + + Response createSuperuser = UtilIT.createRandomUser(); + createSuperuser.then().assertThat() + .statusCode(200); + String superuserUsername = JsonPath.from(createSuperuser.body().asString()).getString("data.user.userName"); + String superUserApiToken = JsonPath.from(createUserToConfirm.body().asString()).getString("data.apiToken"); + + UtilIT.makeSuperUser(superuserUsername); + createSuperuser.then().assertThat() + .statusCode(200); + + System.out.println("not confirmed yet"); + Response getUserWithoutConfirmedEmail = UtilIT.getAuthenticatedUser(usernameToConfirm, superUserApiToken); + getUserWithoutConfirmedEmail.prettyPrint(); + getUserWithoutConfirmedEmail.then().assertThat() + .statusCode(200) + .body("data.emailLastConfirmed", nullValue()); + + Response getToken = given() + .get("/api/admin/confirmEmail/" + userIdToConfirm); + getToken.prettyPrint(); + getToken.then().assertThat() + .statusCode(200); + String confirmEmailToken = JsonPath.from(getToken.body().asString()).getString("data.token"); + + String junkToken = "noSuchToken"; + Response confirmEmailViaBrowserJunkToken = given() + .get("/confirmemail.xhtml?token=" + junkToken); + boolean pageReturnsProper404Response = false; + if (pageReturnsProper404Response) { + confirmEmailViaBrowserJunkToken.then().assertThat().statusCode(404); + } else { + confirmEmailViaBrowserJunkToken.then().assertThat().statusCode(200); + } + + boolean exitEarlyToTestManuallyInBrowser = false; + if (exitEarlyToTestManuallyInBrowser) { + return; + } + + Response confirmEmailViaBrowser = given() + .get("/confirmemail.xhtml?token=" + confirmEmailToken); + confirmEmailViaBrowser.then().assertThat() + .statusCode(200); + + Response getUserWithConfirmedEmail = UtilIT.getAuthenticatedUser(usernameToConfirm, superUserApiToken); + getUserWithConfirmedEmail.prettyPrint(); + getUserWithConfirmedEmail.then().assertThat() + .statusCode(200) + // Checking that it's 2016 or whatever. Not y3k compliant! + .body("data.emailLastConfirmed", startsWith("2")); + + Response getToken2 = given() + .get("/api/admin/confirmEmail/" + userIdToConfirm); + getToken2.prettyPrint(); + getToken2.then().assertThat() + .statusCode(400); + + Response confirmAgain1 = given() + .post("/api/admin/confirmEmail/" + userIdToConfirm); + confirmAgain1.prettyPrint(); + confirmAgain1.then().assertThat() + .statusCode(200); + + Response confirmAgain2 = given() + .post("/api/admin/confirmEmail/" + userIdToConfirm); + confirmAgain2.prettyPrint(); + confirmAgain2.then().assertThat() + .statusCode(400); + + } + + @Test + public void testConfirmUserWithTokenCanBeDeleted() { + + Response createUserToConfirm = UtilIT.createRandomUser(); + createUserToConfirm.prettyPrint(); + createUserToConfirm.then().assertThat() + .statusCode(200); + + long userIdToConfirm = JsonPath.from(createUserToConfirm.body().asString()).getLong("data.authenticatedUser.id"); + String userToConfirmApiToken = JsonPath.from(createUserToConfirm.body().asString()).getString("data.apiToken"); + String usernameToConfirm = JsonPath.from(createUserToConfirm.body().asString()).getString("data.user.userName"); + + Response attemptToDeleteNonExistentUser = UtilIT.deleteUser("noSuchUser"); + attemptToDeleteNonExistentUser.then().assertThat() + .statusCode(400); + + Response deleteUser = UtilIT.deleteUser(usernameToConfirm); + deleteUser.prettyPrint(); + deleteUser.then().assertThat() + .statusCode(200); + + } + +} diff --git a/src/test/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailUtilTest.java b/src/test/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailUtilTest.java new file mode 100644 index 00000000000..5a2a13bbbd9 --- /dev/null +++ b/src/test/java/edu/harvard/iq/dataverse/confirmemail/ConfirmEmailUtilTest.java @@ -0,0 +1,42 @@ +package edu.harvard.iq.dataverse.confirmemail; + +import java.sql.Timestamp; +import static org.junit.Assert.assertEquals; +import org.junit.Test; + +public class ConfirmEmailUtilTest { + + @Test + public void testFriendlyExpirationTime() { + ConfirmEmailUtil confirmEmailUtil = new ConfirmEmailUtil(); + System.out.println("Friendly expiration timestamp / measurement test"); + System.out.println("1440 Minutes: " + confirmEmailUtil.friendlyExpirationTime(1440)); + assertEquals("24 hours", ConfirmEmailUtil.friendlyExpirationTime(1440)); + System.out.println("60 Minutes: " + confirmEmailUtil.friendlyExpirationTime(60)); + assertEquals("1 hour", ConfirmEmailUtil.friendlyExpirationTime(60)); + System.out.println("30 Minutes: " + confirmEmailUtil.friendlyExpirationTime(30)); + assertEquals("30 minutes", ConfirmEmailUtil.friendlyExpirationTime(30)); + System.out.println("90 Minutes: " + confirmEmailUtil.friendlyExpirationTime(90)); + assertEquals("1.5 hours", confirmEmailUtil.friendlyExpirationTime(90)); + System.out.println("2880 minutes: " + confirmEmailUtil.friendlyExpirationTime(2880)); + assertEquals("48 hours", confirmEmailUtil.friendlyExpirationTime(2880)); + System.out.println("150 minutes: " + confirmEmailUtil.friendlyExpirationTime(150)); + assertEquals("2.5 hours", confirmEmailUtil.friendlyExpirationTime(150)); + System.out.println("165 minutes: " + confirmEmailUtil.friendlyExpirationTime(165)); + assertEquals("2.75 hours", confirmEmailUtil.friendlyExpirationTime(165)); + System.out.println("1 Minute: " + confirmEmailUtil.friendlyExpirationTime(1)); + assertEquals("1 minute", confirmEmailUtil.friendlyExpirationTime(1)); + System.out.println(); + } + + @Test + public void testGrandfatheredTime() { + ConfirmEmailUtil confirmEmailUtil = new ConfirmEmailUtil(); + System.out.println(); + System.out.println("Grandfathered account timestamp test"); + System.out.println("Grandfathered Time (y2k): " + confirmEmailUtil.getGrandfatheredTime()); + assertEquals(Timestamp.valueOf("2000-01-01 00:00:00.0"), confirmEmailUtil.getGrandfatheredTime()); + System.out.println(); + } + +} diff --git a/src/test/java/edu/harvard/iq/dataverse/util/BundleUtilTest.java b/src/test/java/edu/harvard/iq/dataverse/util/BundleUtilTest.java index bb58deb3030..b5682e82b06 100644 --- a/src/test/java/edu/harvard/iq/dataverse/util/BundleUtilTest.java +++ b/src/test/java/edu/harvard/iq/dataverse/util/BundleUtilTest.java @@ -65,7 +65,8 @@ public void testGetStringFromBundleWithArguments() { Arrays.asList(BundleUtil.getStringFromBundle("shib.welcomeExistingUserMessageDefaultInstitution")))); assertEquals("Welcome to Root Dataverse! Get started by adding or finding data. " + "Have questions? Check out the User Guide." - + " Want to test out Dataverse features? Use our Demo Site.", + + " Want to test out Dataverse features? Use our Demo Site." + + " Also, check for your welcome email to verify your address.", BundleUtil.getStringFromBundle("notification.welcome", Arrays.asList("Root", "User Guide", "Demo Site"))); }