package edu.harvard.iq.dataverse; import edu.harvard.iq.dataverse.authorization.AuthenticatedUserDisplayInfo; import edu.harvard.iq.dataverse.authorization.AuthenticationServiceBean; import edu.harvard.iq.dataverse.authorization.UserIdentifier; import edu.harvard.iq.dataverse.authorization.UserRecordIdentifier; import edu.harvard.iq.dataverse.authorization.groups.GroupServiceBean; import edu.harvard.iq.dataverse.authorization.providers.builtin.BuiltinUser; import edu.harvard.iq.dataverse.authorization.providers.shib.ShibAuthenticationProvider; import edu.harvard.iq.dataverse.authorization.providers.shib.ShibServiceBean; import edu.harvard.iq.dataverse.authorization.providers.shib.ShibUserNameFields; import edu.harvard.iq.dataverse.authorization.providers.shib.ShibUtil; import edu.harvard.iq.dataverse.authorization.users.AuthenticatedUser; import edu.harvard.iq.dataverse.util.BundleUtil; import edu.harvard.iq.dataverse.util.JsfHelper; import java.io.IOException; import java.sql.Timestamp; import java.util.ArrayList; import java.util.Date; import java.util.List; import java.util.logging.Logger; import javax.ejb.EJB; import javax.ejb.EJBException; import javax.faces.application.FacesMessage; import javax.faces.context.ExternalContext; import javax.faces.context.FacesContext; import javax.faces.view.ViewScoped; import javax.inject.Inject; import javax.inject.Named; import javax.servlet.http.HttpServletRequest; @ViewScoped @Named("Shib") public class Shib implements java.io.Serializable { private static final Logger logger = Logger.getLogger(Shib.class.getCanonicalName()); @Inject DataverseSession session; @EJB AuthenticationServiceBean authSvc; @EJB ShibServiceBean shibService; @EJB DataverseServiceBean dataverseService; @EJB GroupServiceBean groupService; @EJB UserNotificationServiceBean userNotificationService; HttpServletRequest request; private String userPersistentId; private String internalUserIdentifer; AuthenticatedUserDisplayInfo displayInfo; /** * @todo Remove this boolean some day? Now the mockups show a popup. Should * be re-worked. See also the comment about the lack of a Cancel button. */ private boolean visibleTermsOfUse; private final String loginpage = "/loginpage.xhtml"; private final String identityProviderProblem = "Problem with Identity Provider"; /** * We only have one field in which to store a unique * useridentifier/persistentuserid so we have to jam the the "entityId" for * a Shibboleth Identity Provider (IdP) and the unique persistent identifier * per user into the same field and a separator between these two would be * nice, in case we ever want to answer questions like "How many users * logged in from Harvard's Identity Provider?". * * A pipe ("|") is used as a separator because it's considered "unwise" to * use in a URL and the "entityId" for a Shibboleth Identity Provider (IdP) * looks like a URL: * http://stackoverflow.com/questions/1547899/which-characters-make-a-url-invalid */ private String persistentUserIdSeparator = "|"; /** * The Shibboleth Identity Provider (IdP), an "entityId" which often but not * always looks like a URL. */ String shibIdp; private String builtinUsername; private String builtinPassword; private String existingEmail; private String existingDisplayName; private boolean passwordRejected; private String displayNameToPersist; private String emailToPersist; private String affiliationToDisplayAtConfirmation = null; private String friendlyNameForInstitution = BundleUtil.getStringFromBundle("shib.welcomeExistingUserMessageDefaultInstitution"); private State state; private String debugSummary; /** * After a successful login, we will redirect users to this page (unless * it's a new account). */ private String redirectPage; // private boolean debug = false; private String emailAddress; public enum State { INIT, REGULAR_LOGIN_INTO_EXISTING_SHIB_ACCOUNT, PROMPT_TO_CREATE_NEW_ACCOUNT, PROMPT_TO_CONVERT_EXISTING_ACCOUNT, }; public void init() { state = State.INIT; ExternalContext context = FacesContext.getCurrentInstance().getExternalContext(); request = (HttpServletRequest) context.getRequest(); ShibUtil.printAttributes(request); /** * @todo Investigate why JkEnvVar is null since it may be useful for * debugging per https://github.com/IQSS/dataverse/issues/2916 . See * also * http://stackoverflow.com/questions/30193117/iterate-through-all-servletrequest-attributes#comment49933342_30193117 * and * http://shibboleth.1660669.n2.nabble.com/Why-doesn-t-Java-s-request-getAttributeNames-show-Shibboleth-attributes-tp7616427p7616591.html */ logger.fine("JkEnvVar: " + System.getenv("JkEnvVar")); shibService.possiblyMutateRequestInDev(request); try { shibIdp = getRequiredValueFromAssertion(ShibUtil.shibIdpAttribute); } catch (Exception ex) { /** * @todo is in an antipattern to throw exceptions to control flow? * http://c2.com/cgi/wiki?DontUseExceptionsForFlowControl * * All this exception handling should be handled in the new * ShibServiceBean so it's consistently handled by the API as well. */ return; } String shibUserIdentifier; try { shibUserIdentifier = getRequiredValueFromAssertion(ShibUtil.uniquePersistentIdentifier); } catch (Exception ex) { return; } String firstName; try { firstName = getRequiredValueFromAssertion(ShibUtil.firstNameAttribute); } catch (Exception ex) { return; } String lastName; try { lastName = getRequiredValueFromAssertion(ShibUtil.lastNameAttribute); } catch (Exception ex) { return; } ShibUserNameFields shibUserNameFields = ShibUtil.findBestFirstAndLastName(firstName, lastName, null); if (shibUserNameFields != null) { String betterFirstName = shibUserNameFields.getFirstName(); if (betterFirstName != null) { firstName = betterFirstName; } String betterLastName = shibUserNameFields.getLastName(); if (betterLastName != null) { lastName = betterLastName; } } String emailAddressInAssertion = null; try { emailAddressInAssertion = getRequiredValueFromAssertion(ShibUtil.emailAttribute); } catch (Exception ex) { if (shibIdp.equals(ShibUtil.testShibIdpEntityId)) { logger.info("For " + shibIdp + " (which as of this writing doesn't provide the " + ShibUtil.emailAttribute + " attribute) setting email address to value of eppn: " + shibUserIdentifier); emailAddressInAssertion = shibUserIdentifier; } else { // forcing all other IdPs to send us an an email return; } } if (!EMailValidator.isEmailValid(emailAddressInAssertion, null)) { String msg = "The SAML assertion contained an invalid email address: \"" + emailAddressInAssertion + "\"."; logger.info(msg); String singleEmailAddress = ShibUtil.findSingleValue(emailAddressInAssertion); if (EMailValidator.isEmailValid(singleEmailAddress, null)) { msg = "Multiple email addresses were asserted by the Identity Provider (" + emailAddressInAssertion + " ). These were sorted and the first was chosen: " + singleEmailAddress; logger.info(msg); emailAddress = singleEmailAddress; } else { msg += " A single valid address could not be found."; FacesContext.getCurrentInstance().addMessage(null, new FacesMessage(FacesMessage.SEVERITY_ERROR, identityProviderProblem, msg)); return; } } else { emailAddress = emailAddressInAssertion; } String usernameAssertion = getValueFromAssertion(ShibUtil.usernameAttribute); internalUserIdentifer = ShibUtil.generateFriendlyLookingUserIdentifer(usernameAssertion, emailAddress); logger.fine("friendly looking identifer (backend will enforce uniqueness):" + internalUserIdentifer); String affiliation = shibService.getAffiliation(shibIdp, shibService.getDevShibAccountType()); if (affiliation != null) { affiliationToDisplayAtConfirmation = affiliation; friendlyNameForInstitution = affiliation; } // emailAddress = "willFailBeanValidation"; // for testing createAuthenticatedUser exceptions displayInfo = new AuthenticatedUserDisplayInfo(firstName, lastName, emailAddress, affiliation, null); userPersistentId = shibIdp + persistentUserIdSeparator + shibUserIdentifier; ShibAuthenticationProvider shibAuthProvider = new ShibAuthenticationProvider(); AuthenticatedUser au = authSvc.lookupUser(shibAuthProvider.getId(), userPersistentId); if (au != null) { state = State.REGULAR_LOGIN_INTO_EXISTING_SHIB_ACCOUNT; logger.fine("Found user based on " + userPersistentId + ". Logging in."); logger.fine("Updating display info for " + au.getName()); authSvc.updateAuthenticatedUser(au, displayInfo); logInUserAndSetShibAttributes(au); String prettyFacesHomePageString = getPrettyFacesHomePageString(false); try { FacesContext.getCurrentInstance().getExternalContext().redirect(prettyFacesHomePageString); } catch (IOException ex) { logger.info("Unable to redirect user to homepage at " + prettyFacesHomePageString); } } else { state = State.PROMPT_TO_CREATE_NEW_ACCOUNT; displayNameToPersist = displayInfo.getTitle(); emailToPersist = emailAddress; /** * @todo for Harvard we plan to use the value(s) from * eduPersonScopedAffiliation which * http://iam.harvard.edu/resources/saml-shibboleth-attributes says * can be One or more of the following values: faculty, staff, * student, affiliate, and member. * * http://dataverse.nl plans to use * urn:mace:dir:attribute-def:eduPersonAffiliation per * http://irclog.iq.harvard.edu/dataverse/2015-02-13#i_16265 . Can * they configure shibd to map eduPersonAffiliation to * eduPersonScopedAffiliation? */ // positionToPersist = "FIXME"; logger.fine("Couldn't find authenticated user based on " + userPersistentId); visibleTermsOfUse = true; /** * Using the email address from the IdP, try to find an existing * user. For TestShib we convert the "eppn" to an email address. * * If found, prompt for password and offer to convert. * * If not found, create a new account. It must be a new user. */ String emailAddressToLookUp = emailAddress; if (existingEmail != null) { emailAddressToLookUp = existingEmail; } AuthenticatedUser existingAuthUserFoundByEmail = shibService.findAuthUserByEmail(emailAddressToLookUp); BuiltinUser existingBuiltInUserFoundByEmail = null; if (existingAuthUserFoundByEmail != null) { existingDisplayName = existingAuthUserFoundByEmail.getName(); existingBuiltInUserFoundByEmail = shibService.findBuiltInUserByAuthUserIdentifier(existingAuthUserFoundByEmail.getUserIdentifier()); if (existingBuiltInUserFoundByEmail != null) { state = State.PROMPT_TO_CONVERT_EXISTING_ACCOUNT; existingDisplayName = existingBuiltInUserFoundByEmail.getDisplayName(); debugSummary = "getting username from the builtin user we looked up via email"; builtinUsername = existingBuiltInUserFoundByEmail.getUserName(); } else { debugSummary = "Could not find a builtin account based on the username. Here we should simply create a new Shibboleth user"; } } else { debugSummary = "Could not find an auth user based on email address"; } } logger.fine("Debug summary: " + debugSummary + " (state: " + state + ")."); logger.fine("redirectPage: " + redirectPage); } public String confirmAndCreateAccount() { ShibAuthenticationProvider shibAuthProvider = new ShibAuthenticationProvider(); String lookupStringPerAuthProvider = userPersistentId; AuthenticatedUser au = null; try { au = authSvc.createAuthenticatedUser( new UserRecordIdentifier(shibAuthProvider.getId(), lookupStringPerAuthProvider), internalUserIdentifer, displayInfo, true); } catch (EJBException ex) { /** * @todo Show the ConstraintViolationException, if any. */ logger.info("Couldn't create user " + userPersistentId + " due to exception: " + ex.getCause()); } 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); return "/dataverseuser.xhtml?selectTab=accountInfo&faces-redirect=true"; } else { JsfHelper.addErrorMessage("Couldn't create user."); } return getPrettyFacesHomePageString(true); } public String confirmAndConvertAccount() { visibleTermsOfUse = false; ShibAuthenticationProvider shibAuthProvider = new ShibAuthenticationProvider(); String lookupStringPerAuthProvider = userPersistentId; UserIdentifier userIdentifier = new UserIdentifier(lookupStringPerAuthProvider, internalUserIdentifer); logger.fine("builtin username: " + builtinUsername); AuthenticatedUser builtInUserToConvert = shibService.canLogInAsBuiltinUser(builtinUsername, builtinPassword); if (builtInUserToConvert != null) { AuthenticatedUser au = authSvc.convertBuiltInToShib(builtInUserToConvert, shibAuthProvider.getId(), userIdentifier); if (au != null) { authSvc.updateAuthenticatedUser(au, displayInfo); logInUserAndSetShibAttributes(au); debugSummary = "Local account validated and successfully converted to a Shibboleth account. The old account username was " + builtinUsername; JsfHelper.addSuccessMessage("Your Dataverse account is now associated with your institutional account."); return "/dataverseuser.xhtml?selectTab=accountInfo&faces-redirect=true"; } else { debugSummary = "Local account validated but unable to convert to Shibboleth account."; } } else { passwordRejected = true; debugSummary = "Username/password combination for local account was invalid"; } return null; } private void logInUserAndSetShibAttributes(AuthenticatedUser au) { au.setShibIdentityProvider(shibIdp); session.setUser(au); logger.fine("Groups for user " + au.getId() + " (" + au.getIdentifier() + "): " + getGroups(au)); } public List<String> getGroups(AuthenticatedUser au) { List<String> groups = new ArrayList<>(); groupService.groupsFor(au, null).stream().forEach((group) -> { groups.add(group.getDisplayName() + " (" + group.getIdentifier() + ")"); }); return groups; } /** * @todo The mockups show a Cancel button but because we're using the * "requiredCheckboxValidator" you are forced to agree to Terms of Use * before clicking Cancel! Argh! The mockups show how we want to display * Terms of Use in a popup anyway so this should all be re-done. No time * now. Here's the mockup: * https://iqssharvard.mybalsamiq.com/projects/loginwithshibboleth-version3-dataverse40/Dataverse%20Account%20III%20-%20Agree%20Terms%20of%20Use */ public String cancel() { return loginpage + "?faces-redirect=true"; } /** * @return The trimmed value of a Shib attribute (if non-empty) or null. * * @todo Move this to ShibUtil */ private String getValueFromAssertion(String key) { Object attribute = request.getAttribute(key); if (attribute != null) { String attributeValue = attribute.toString(); String trimmedValue = attributeValue.trim(); if (!trimmedValue.isEmpty()) { logger.fine("The SAML assertion for \"" + key + "\" (optional) was \"" + attributeValue + "\" and was trimmed to \"" + trimmedValue + "\"."); return trimmedValue; } else { logger.fine("The SAML assertion for \"" + key + "\" (optional) was \"" + attributeValue + "\" and was trimmed to \"" + trimmedValue + "\" (empty string). Returing null."); return null; } } else { logger.fine("The SAML assertion for \"" + key + "\" (optional) was null."); return null; } } /** * @return The trimmed value of a Shib attribute (if non-empty) or null. * * @todo Move this to ShibUtil. More objects might be required since * sometimes we want to show messages, etc. */ private String getRequiredValueFromAssertion(String key) throws Exception { Object attribute = request.getAttribute(key); if (attribute == null) { String msg = "The SAML assertion for \"" + key + "\" was null. Please contact support."; logger.info(msg); boolean showMessage = true; if (shibIdp.equals(ShibUtil.testShibIdpEntityId) && key.equals(ShibUtil.emailAttribute)) { showMessage = false; } if (showMessage) { FacesContext.getCurrentInstance().addMessage(null, new FacesMessage(FacesMessage.SEVERITY_ERROR, identityProviderProblem, msg)); } throw new Exception(msg); } String attributeValue = attribute.toString(); if (attributeValue.isEmpty()) { throw new Exception(key + " was empty"); } String trimmedValue = attributeValue.trim(); logger.fine("The SAML assertion for \"" + key + "\" (required) was \"" + attributeValue + "\" and was trimmed to \"" + trimmedValue + "\"."); return trimmedValue; } public String getRootDataverseAlias() { Dataverse rootDataverse = dataverseService.findRootDataverse(); if (rootDataverse != null) { String rootDvAlias = rootDataverse.getAlias(); if (rootDvAlias != null) { return rootDvAlias; } } return null; } /** * @param includeFacetDashRedirect if true, include "faces-redirect=true" in * the string * * @todo Once https://github.com/IQSS/dataverse/issues/1519 is done, revisit * this method and have the home page be "/" rather than "/dataverses/root". * * @todo Like builtin users, Shibboleth should benefit from redirectPage * logic per https://github.com/IQSS/dataverse/issues/1551 */ public String getPrettyFacesHomePageString(boolean includeFacetDashRedirect) { if (redirectPage != null) { return redirectPage; } String plainHomepageString = "/dataverse.xhtml"; String rootDvAlias = getRootDataverseAlias(); if (includeFacetDashRedirect) { if (rootDvAlias != null) { return plainHomepageString + "?alias=" + rootDvAlias + "&faces-redirect=true"; } else { return plainHomepageString + "?faces-redirect=true"; } } else if (rootDvAlias != null) { /** * @todo Is there a constant for "/dataverse/" anywhere? I guess * we'll just hard-code it here. */ return "/dataverse/" + rootDvAlias; } else { return plainHomepageString; } } public boolean isInit() { return state.equals(State.INIT); } public boolean isOfferToCreateNewAccount() { return state.equals(State.PROMPT_TO_CREATE_NEW_ACCOUNT); } public boolean isOfferToConvertExistingAccount() { return state.equals(State.PROMPT_TO_CONVERT_EXISTING_ACCOUNT); } public String getDisplayNameToPersist() { return displayNameToPersist; } public String getEmailToPersist() { return emailToPersist; } public String getAffiliationToDisplayAtConfirmation() { return affiliationToDisplayAtConfirmation; } public String getExistingEmail() { return existingEmail; } public void setExistingEmail(String existingEmail) { this.existingEmail = existingEmail; } public String getExistingDisplayName() { return existingDisplayName; } public boolean isPasswordRejected() { return passwordRejected; } public String getFriendlyNameForInstitution() { return friendlyNameForInstitution; } public void setFriendlyNameForInstitution(String friendlyNameForInstitution) { this.friendlyNameForInstitution = friendlyNameForInstitution; } public State getState() { return state; } public boolean isVisibleTermsOfUse() { return visibleTermsOfUse; } public String getBuiltinUsername() { return builtinUsername; } public void setBuiltinUsername(String builtinUsername) { this.builtinUsername = builtinUsername; } public String getBuiltinPassword() { return builtinPassword; } public void setBuiltinPassword(String builtinPassword) { this.builtinPassword = builtinPassword; } public String getDebugSummary() { return debugSummary; } public void setDebugSummary(String debugSummary) { this.debugSummary = debugSummary; } public String getRedirectPage() { return redirectPage; } public void setRedirectPage(String redirectPage) { this.redirectPage = redirectPage; } }