From c471ae772793335204a2b2b94adc62a5d80a34fd Mon Sep 17 00:00:00 2001 From: remillet Date: Mon, 20 Nov 2017 21:55:28 -0800 Subject: [PATCH] DRYD-169: Support for new password reset mechanism. Password reset is now negotiated between only the UI and Services layer -App layer (except for config settings) is left out of the process. --- .../main/resources/META-INF/persistence.xml | 1 + .../WEB-INF/applicationContext-security.xml | 6 + .../services/client/AccountClient.java | 1 + .../src/main/resources/accounts_common.xsd | 13 +- .../services/account/AccountResource.java | 237 ++++++++- .../storage/AccountDocumentHandler.java | 2 + .../account/storage/AccountStorageClient.java | 15 +- .../storage/AccountStorageConstants.java | 5 +- .../storage/csidp/TokenStorageClient.java | 182 +++++++ .../authentication_identity_provider.xsd | 80 ++++ .../db/postgresql/authentication.sql | 3 + .../spring/SpringAuthNContext.java | 17 +- .../services/common/EmailUtil.java | 109 +++++ .../services/common/SecurityResourceBase.java | 16 +- .../services/common/ServiceMessages.java | 1 + .../AuthorizationCommon.java | 101 ++++ .../common/security/SecurityContextImpl.java | 13 +- .../common/security/SecurityInterceptor.java | 30 +- .../config/src/main/resources/instance1.xml | 453 ------------------ services/config/src/main/resources/tenant.xsd | 48 ++ 20 files changed, 838 insertions(+), 495 deletions(-) create mode 100644 services/account/service/src/main/java/org/collectionspace/services/account/storage/csidp/TokenStorageClient.java create mode 100644 services/common/src/main/java/org/collectionspace/services/common/EmailUtil.java delete mode 100644 services/config/src/main/resources/instance1.xml diff --git a/services/JaxRsServiceProvider/src/main/resources/META-INF/persistence.xml b/services/JaxRsServiceProvider/src/main/resources/META-INF/persistence.xml index 254aff900..a01685418 100644 --- a/services/JaxRsServiceProvider/src/main/resources/META-INF/persistence.xml +++ b/services/JaxRsServiceProvider/src/main/resources/META-INF/persistence.xml @@ -14,6 +14,7 @@ org.collectionspace.services.account.AccountTenant org.collectionspace.services.account.Status org.collectionspace.services.authentication.User + org.collectionspace.services.authentication.Token org.collectionspace.services.authorization.perms.Permission org.collectionspace.services.authorization.perms.PermissionAction org.collectionspace.services.authorization.PermissionRoleRel diff --git a/services/JaxRsServiceProvider/src/main/webapp/WEB-INF/applicationContext-security.xml b/services/JaxRsServiceProvider/src/main/webapp/WEB-INF/applicationContext-security.xml index a1f23a938..48f9d8e87 100644 --- a/services/JaxRsServiceProvider/src/main/webapp/WEB-INF/applicationContext-security.xml +++ b/services/JaxRsServiceProvider/src/main/webapp/WEB-INF/applicationContext-security.xml @@ -56,6 +56,12 @@ + + + + + + diff --git a/services/account/client/src/main/java/org/collectionspace/services/client/AccountClient.java b/services/account/client/src/main/java/org/collectionspace/services/client/AccountClient.java index d97cf2a53..6b4d8f1ae 100644 --- a/services/account/client/src/main/java/org/collectionspace/services/client/AccountClient.java +++ b/services/account/client/src/main/java/org/collectionspace/services/client/AccountClient.java @@ -45,6 +45,7 @@ public class AccountClient extends AbstractServiceClientImpl + + + + + tenant association is usually not required to be provided by the + service consumer. only in cases where a user in CollectionSpace + has access to the spaces of multiple tenants, this is used + to associate that user with more than one tenants + + + @@ -281,7 +292,7 @@ - + diff --git a/services/account/service/src/main/java/org/collectionspace/services/account/AccountResource.java b/services/account/service/src/main/java/org/collectionspace/services/account/AccountResource.java index 988468509..090f8ab55 100644 --- a/services/account/service/src/main/java/org/collectionspace/services/account/AccountResource.java +++ b/services/account/service/src/main/java/org/collectionspace/services/account/AccountResource.java @@ -23,7 +23,10 @@ */ package org.collectionspace.services.account; +import org.collectionspace.authentication.AuthN; import org.collectionspace.services.account.storage.AccountStorageClient; +import org.collectionspace.services.account.storage.csidp.TokenStorageClient; +import org.collectionspace.services.authentication.Token; import org.collectionspace.services.authorization.AccountPermission; import org.collectionspace.services.authorization.AccountRole; import org.collectionspace.services.authorization.AccountRoleRel; @@ -32,21 +35,28 @@ import org.collectionspace.services.authorization.SubjectType; import org.collectionspace.services.client.AccountClient; import org.collectionspace.services.client.PayloadOutputPart; import org.collectionspace.services.client.RoleClient; +import org.collectionspace.services.common.EmailUtil; import org.collectionspace.services.common.SecurityResourceBase; +import org.collectionspace.services.common.ServiceMain; import org.collectionspace.services.common.ServiceMessages; import org.collectionspace.services.common.UriInfoWrapper; +import org.collectionspace.services.common.authorization_mgt.AuthorizationCommon; import org.collectionspace.services.common.context.RemoteServiceContextFactory; import org.collectionspace.services.common.context.ServiceContext; import org.collectionspace.services.common.context.ServiceContextFactory; +import org.collectionspace.services.common.document.DocumentNotFoundException; import org.collectionspace.services.common.query.UriInfoImpl; import org.collectionspace.services.common.storage.StorageClient; import org.collectionspace.services.common.storage.jpa.JpaStorageUtils; +import org.collectionspace.services.config.tenant.EmailConfig; +import org.collectionspace.services.config.tenant.TenantBindingType; import org.jboss.resteasy.util.HttpResponseCodes; import org.slf4j.Logger; import org.slf4j.LoggerFactory; import java.net.URI; import java.net.URISyntaxException; +import java.security.NoSuchAlgorithmException; import java.util.ArrayList; import java.util.Collections; import java.util.List; @@ -61,6 +71,7 @@ import javax.ws.rs.PathParam; import javax.ws.rs.Produces; import javax.ws.rs.QueryParam; import javax.ws.rs.core.Context; +import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.PathSegment; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; @@ -73,8 +84,10 @@ import javax.ws.rs.core.UriInfo; @Produces("application/xml") public class AccountResource extends SecurityResourceBase { - final Logger logger = LoggerFactory.getLogger(AccountResource.class); + final Logger logger = LoggerFactory.getLogger(AccountResource.class); final StorageClient storageClient = new AccountStorageClient(); + private static final String PASSWORD_RESET_PATH = "requestpasswordreset"; + private static final String PROCESS_PASSWORD_RESET_PATH = "processpasswordreset"; @Override protected String getVersionString() { @@ -109,8 +122,8 @@ public class AccountResource extends SecurityResourceBase { @GET @Path("{csid}") - public AccountsCommon getAccount(@PathParam("csid") String csid) { - return (AccountsCommon)get(csid, AccountsCommon.class); + public AccountsCommon getAccount(@Context UriInfo ui, @PathParam("csid") String csid) { + return (AccountsCommon)get(ui, csid, AccountsCommon.class); } @GET @@ -195,12 +208,224 @@ public class AccountResource extends SecurityResourceBase { @PUT @Path("{csid}") - public AccountsCommon updateAccount(@PathParam("csid") String csid,AccountsCommon theUpdate) { - return (AccountsCommon)update(csid, theUpdate, AccountsCommon.class); + public AccountsCommon updateAccount(@Context UriInfo ui, @PathParam("csid") String csid,AccountsCommon theUpdate) { + return (AccountsCommon)update(ui, csid, theUpdate, AccountsCommon.class); } + + /** + * Resets an accounts password. + * + * Requires three query params: + * id = CSID of the account + * token = the password reset token generated by the system + * password = the new password + * + * @param ui + * @return + */ + @POST + @Path(PROCESS_PASSWORD_RESET_PATH) + public Response processPasswordReset(@Context UriInfo ui) { + Response response = null; + // + // Create a read/write copy of the UriInfo info + // + ui = new UriInfoWrapper(ui); + MultivaluedMap queryParams = ui.getQueryParameters(); + + // + // Get the 'token' and 'password' params + // + String tokenId = queryParams.getFirst("token"); + if (tokenId == null || tokenId.trim().isEmpty()) { + response = Response.status(Response.Status.BAD_REQUEST).entity( + "The query parameter 'token' is missing or contains no value.").type("text/plain").build(); + return response; + } + + String password = queryParams.getFirst("password"); + if (password == null || password.trim().isEmpty()) { + response = Response.status(Response.Status.BAD_REQUEST).entity( + "The query parameter 'password' is missing or contains no value.").type("text/plain").build(); + return response; + } + + // + // Retrieve the token from the DB + // + Token token; + try { + token = TokenStorageClient.get(tokenId); + } catch (DocumentNotFoundException e1) { + String errMsg = String.format("The token '%s' is not valid or does not exist.", + tokenId); + response = Response.status(Response.Status.BAD_REQUEST).entity(errMsg).type("text/plain").build(); + return response; + } + + // + // Make sure the token is not null + // + if (token == null) { + String errMsg = String.format("The token '%s' is not valid.", + tokenId); + response = Response.status(Response.Status.BAD_REQUEST).entity(errMsg).type("text/plain").build(); + return response; + } + + // + // From the token, get the account to update. + // + queryParams.add(AuthN.TENANT_ID_QUERY_PARAM, token.getTenantId()); + AccountsCommon targetAccount = getAccount(ui, token.getAccountCsid()); + if (targetAccount == null) { + String errMsg = String.format("The token '%s' is not valid. The account it was created for no longer exists.", + tokenId); + response = Response.status(Response.Status.BAD_REQUEST).entity(errMsg).type("text/plain").build(); + return response; + } + + // + // + // + String tenantId = token.getTenantId(); + TenantBindingType tenantBindingType = ServiceMain.getInstance().getTenantBindingConfigReader().getTenantBinding(tenantId); + EmailConfig emailConfig = tenantBindingType.getEmailConfig(); + if (emailConfig != null) { + try { + if (AuthorizationCommon.hasTokenExpired(emailConfig, token) == false) { + AccountsCommon accountUpdate = new AccountsCommon(); + accountUpdate.setUserId(targetAccount.getUserId()); + accountUpdate.setPassword(password.getBytes()); + updateAccount(ui, targetAccount.getCsid(), accountUpdate); + String msg = String.format("Successfully reset password using token ID='%s'.", + token.getId()); + response = Response.status(Response.Status.OK).entity(msg).type("text/plain").build(); + } else { + String errMsg = String.format("Could not reset password using token with ID='%s'. Password reset token has expired.", + token.getId()); + response = Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(errMsg).type("text/plain").build(); + } + } catch (NoSuchAlgorithmException e) { + String errMsg = String.format("Could not reset password for using token ID='%s'. Error: '%s'", + e.getMessage(), token.getId()); + response = Response.status(Response.Status.BAD_REQUEST).entity(errMsg).type("text/plain").build(); + } + } else { + String errMsg = String.format("The email configuration for tenant ID='%s' is missing. Please ask your CollectionSpace administrator to check the configuration.", + tenantId); + response = Response.status(Response.Status.BAD_REQUEST).entity(errMsg).type("text/plain").build(); + } + + return response; + } + + @POST + @Path(PASSWORD_RESET_PATH) + public Response requestPasswordReset(@Context UriInfo ui) { + Response response = null; + + MultivaluedMap queryParams = ui.getQueryParameters(); + String email = queryParams.getFirst(AccountClient.EMAIL_QUERY_PARAM); + if (email == null) { + response = Response.status(Response.Status.BAD_REQUEST).entity("You must specify an 'email' query paramater.").type("text/plain").build(); + return response; + } - @DELETE + String tenantId = queryParams.getFirst(AuthN.TENANT_ID_QUERY_PARAM); + if (tenantId == null) { + response = Response.status(Response.Status.BAD_REQUEST).entity("You must specify an 'tid' (tenant ID) query paramater.").type("text/plain").build(); + return response; + } + + AccountsCommonList accountList = getAccountList(ui); + if (accountList == null || accountList.getTotalItems() == 0) { + response = Response.status(Response.Status.NOT_FOUND).entity("Could not locatate an account associated with the email: " + + email).type("text/plain").build(); + } else if (accountList.getTotalItems() > 1) { + response = Response.status(Response.Status.BAD_REQUEST).entity("Located more than one account associated with the email: " + + email).type("text/plain").build(); + } else { + AccountListItem accountListItem = accountList.getAccountListItem().get(0); + try { + response = requestPasswordReset(ui, tenantId, accountListItem); + } catch (Exception e) { + response = Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(e.getMessage()).type("text/plain").build(); + } + } + + return response; + } + + private boolean contains(String targetTenantID, List accountTenantList) { + boolean result = false; + + for (AccountTenant accountTenant : accountTenantList) { + if (accountTenant.getTenantId().equalsIgnoreCase(targetTenantID)) { + result = true; + break; + } + } + + return result; + } + + /* + * Sends an email to a user allow them to reset their password. + */ + private Response requestPasswordReset(UriInfo ui, String targetTenantID, AccountListItem accountListItem) throws Exception { + Response result = null; + + if (contains(targetTenantID, accountListItem.getTenants()) == false) { + String errMsg = String.format("Could not send a password request email to user ID='%s'. That account is not associtated with the targeted tenant ID = '%s'.", + accountListItem.email, targetTenantID); + result = Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(errMsg).type("text/plain").build(); + return result; + } + + TenantBindingType tenantBindingType = ServiceMain.getInstance().getTenantBindingConfigReader().getTenantBinding(targetTenantID); + EmailConfig emailConfig = tenantBindingType.getEmailConfig(); + if (emailConfig != null) { + UriBuilder baseUrlBuilder = ui.getBaseUriBuilder(); + String deprecatedConfigBaseUrl = emailConfig.getBaseurl(); + + Object[] emptyValues = new String[0]; + String baseUrl = baseUrlBuilder.replacePath(null).build(emptyValues).toString(); + emailConfig.setBaseurl(baseUrl); + // + // Configuring (via config files) the base URL is not supported as of CSpace v5.0. Log a warning if we find config for it. + // + if (deprecatedConfigBaseUrl != null) { + if (deprecatedConfigBaseUrl.equalsIgnoreCase(baseUrl) == false) { + String warnMsg = String.format("Ignoring deprecated 'baseurl' email config value '%s'. Using '%s' instead.", + deprecatedConfigBaseUrl, baseUrl); + logger.warn(warnMsg); + } + } + + Token token = TokenStorageClient.create(accountListItem.getCsid(), targetTenantID, + emailConfig.getPasswordResetConfig().getTokenExpirationSeconds()); + String message = AuthorizationCommon.generatePasswordResetEmailMessage(emailConfig, accountListItem, token); + String status = EmailUtil.sendMessage(emailConfig, accountListItem.getEmail(), message); + if (status != null) { + String errMsg = String.format("Could not send a password request email to user ID='%s'. Error: '%s'", + accountListItem.email, status); + result = Response.status(Response.Status.INTERNAL_SERVER_ERROR).entity(errMsg).type("text/plain").build(); + } else { + String okMsg = String.format("Password reset email send to '%s'.", accountListItem.getEmail()); + result = Response.status(Response.Status.OK).entity(okMsg).type("text/plain").build(); + } + } else { + String errMsg = String.format("The email configuration for tenant ID='%s' is missing. Please ask your CollectionSpace administrator to check the configuration.", + targetTenantID); + result = Response.status(Response.Status.BAD_REQUEST).entity(errMsg).type("text/plain").build(); + } + + return result; + } + + @DELETE @Path("{csid}") public Response deleteAccount(@Context UriInfo uriInfo, @PathParam("csid") String csid) { logger.debug("deleteAccount with csid=" + csid); diff --git a/services/account/service/src/main/java/org/collectionspace/services/account/storage/AccountDocumentHandler.java b/services/account/service/src/main/java/org/collectionspace/services/account/storage/AccountDocumentHandler.java index 09a344483..1c7d796f7 100644 --- a/services/account/service/src/main/java/org/collectionspace/services/account/storage/AccountDocumentHandler.java +++ b/services/account/service/src/main/java/org/collectionspace/services/account/storage/AccountDocumentHandler.java @@ -164,6 +164,8 @@ public class AccountDocumentHandler AccountListItem accListItem = new AccountListItem(); accListItem.setScreenName(account.getScreenName()); accListItem.setUserid(account.getUserId()); + accListItem.setTenantid(account.getTenants().get(0).getTenantId()); // pick the default/first tenant + accListItem.setTenants(account.getTenants()); accListItem.setEmail(account.getEmail()); accListItem.setStatus(account.getStatus()); String id = account.getCsid(); diff --git a/services/account/service/src/main/java/org/collectionspace/services/account/storage/AccountStorageClient.java b/services/account/service/src/main/java/org/collectionspace/services/account/storage/AccountStorageClient.java index 2bf5b401e..ea517d1a2 100644 --- a/services/account/service/src/main/java/org/collectionspace/services/account/storage/AccountStorageClient.java +++ b/services/account/service/src/main/java/org/collectionspace/services/account/storage/AccountStorageClient.java @@ -27,6 +27,7 @@ import java.util.Date; import java.util.HashMap; import javax.persistence.EntityManager; import javax.persistence.EntityManagerFactory; + import org.collectionspace.services.account.AccountsCommon; import org.collectionspace.services.account.storage.csidp.UserStorageClient; import org.collectionspace.services.authentication.User; @@ -125,13 +126,13 @@ public class AccountStorageClient extends JpaStorageClientImpl { } throw new DocumentException(e); } finally { - if (em != null) { + if (emf != null) { JpaStorageUtils.releaseEntityManagerFactory(emf); } } } - @Override + @Override public void get(ServiceContext ctx, String id, DocumentHandler handler) throws DocumentNotFoundException, DocumentException { if (ctx == null) { @@ -146,8 +147,7 @@ public class AccountStorageClient extends JpaStorageClientImpl { if (docFilter == null) { docFilter = handler.createDocumentFilter(); } - EntityManagerFactory emf = null; - EntityManager em = null; + try { handler.prepare(Action.GET); Object o = null; @@ -159,9 +159,6 @@ public class AccountStorageClient extends JpaStorageClientImpl { o = JpaStorageUtils.getEntity( "org.collectionspace.services.account.AccountsCommon", whereClause, params); if (null == o) { - if (em != null && em.getTransaction().isActive()) { - em.getTransaction().rollback(); - } String msg = "could not find entity with id=" + id; throw new DocumentNotFoundException(msg); } @@ -175,10 +172,6 @@ public class AccountStorageClient extends JpaStorageClientImpl { logger.debug("Caught exception ", e); } throw new DocumentException(e); - } finally { - if (emf != null) { - JpaStorageUtils.releaseEntityManagerFactory(emf); - } } } diff --git a/services/account/service/src/main/java/org/collectionspace/services/account/storage/AccountStorageConstants.java b/services/account/service/src/main/java/org/collectionspace/services/account/storage/AccountStorageConstants.java index 35b8e250a..88b342899 100644 --- a/services/account/service/src/main/java/org/collectionspace/services/account/storage/AccountStorageConstants.java +++ b/services/account/service/src/main/java/org/collectionspace/services/account/storage/AccountStorageConstants.java @@ -50,6 +50,8 @@ package org.collectionspace.services.account.storage; +import org.collectionspace.services.client.AccountClient; + /** * AccountStorageConstants declares query params, etc. * @author @@ -58,8 +60,7 @@ public class AccountStorageConstants { final public static String Q_SCREEN_NAME = "sn"; final public static String Q_USER_ID= "uid"; - final public static String Q_EMAIL = "email"; - + final public static String Q_EMAIL = AccountClient.EMAIL_QUERY_PARAM; final public static String SCREEN_NAME = "screenName"; final public static String USER_ID = "userId"; diff --git a/services/account/service/src/main/java/org/collectionspace/services/account/storage/csidp/TokenStorageClient.java b/services/account/service/src/main/java/org/collectionspace/services/account/storage/csidp/TokenStorageClient.java new file mode 100644 index 000000000..9d585da3b --- /dev/null +++ b/services/account/service/src/main/java/org/collectionspace/services/account/storage/csidp/TokenStorageClient.java @@ -0,0 +1,182 @@ +/** + * This document is a part of the source code and related artifacts + * for CollectionSpace, an open source collections management system + * for museums and related institutions: + + * http://www.collectionspace.org + * http://wiki.collectionspace.org + + * Copyright 2010 University of California at Berkeley + + * Licensed under the Educational Community License (ECL), Version 2.0. + * You may not use this file except in compliance with this License. + + * You may obtain a copy of the ECL 2.0 License at + + * https://source.collectionspace.org/collection-space/LICENSE.txt + + * 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. + */ +/* + * To change this template, choose Tools | Templates + * and open the template in the editor. + */ +package org.collectionspace.services.account.storage.csidp; + +import java.math.BigInteger; +import java.util.Date; +import java.util.UUID; + +import javax.persistence.EntityManager; +import javax.persistence.EntityManagerFactory; +import javax.persistence.Query; + +import org.collectionspace.services.authentication.Token; +import org.collectionspace.services.common.document.BadRequestException; +import org.collectionspace.services.common.document.DocumentNotFoundException; +import org.collectionspace.services.common.document.JaxbUtils; +import org.collectionspace.services.common.security.SecurityUtils; +import org.collectionspace.services.common.storage.jpa.JpaStorageUtils; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class TokenStorageClient { + + static private final Logger logger = LoggerFactory.getLogger(TokenStorageClient.class); + + /** + * create user with given userId and password + * @param userId + * @param password + * @return user + */ + static public Token create(String accountCsid, String tenantId, BigInteger expireSeconds) { + EntityManagerFactory emf = JpaStorageUtils.getEntityManagerFactory(); + Token token = new Token(); + + try { + EntityManager em = emf.createEntityManager(); + + token.setId(UUID.randomUUID().toString()); + token.setAccountCsid(accountCsid); + token.setTenantId(tenantId); + token.setExpireSeconds(expireSeconds); + token.setEnabled(true); + token.setCreatedAtItem(new Date()); + + em.getTransaction().begin(); + em.persist(token); + em.getTransaction().commit(); + + } finally { + if (emf != null) { + JpaStorageUtils.releaseEntityManagerFactory(emf); + } + } + + return token; + } + + /** + * Get token for given ID + * @param em EntityManager + * @param id + */ + static public Token get(String id) throws DocumentNotFoundException { + EntityManagerFactory emf = JpaStorageUtils.getEntityManagerFactory(); + Token tokenFound = null; + + try { + EntityManager em = emf.createEntityManager(); + em.getTransaction().begin(); + tokenFound = em.find(Token.class, id); + em.getTransaction().commit(); + if (tokenFound == null) { + String msg = "Could not find token with ID=" + id; + logger.error(msg); + throw new DocumentNotFoundException(msg); + } + } finally { + if (emf != null) { + JpaStorageUtils.releaseEntityManagerFactory(emf); + } + } + + return tokenFound; + } + + /** + * Update a token for given an id + * @param id + * @param enabledFlag + */ + static public void update(String id, boolean enabledFlag) throws DocumentNotFoundException { + EntityManagerFactory emf = JpaStorageUtils.getEntityManagerFactory(); + Token tokenFound = null; + + try { + EntityManager em = emf.createEntityManager(); + tokenFound = get(id); + if (id != null) { + tokenFound.setEnabled(enabledFlag); + tokenFound.setUpdatedAtItem(new Date()); + if (logger.isDebugEnabled()) { + logger.debug("Updated token=" + JaxbUtils.toString(tokenFound, Token.class)); + } + em.persist(tokenFound); + } + } finally { + if (emf != null) { + JpaStorageUtils.releaseEntityManagerFactory(emf); + } + } + } + + /** + * Deletes the token with given id + * @param id + * @throws Exception if user for given userId not found + */ + static public void delete(String id) throws DocumentNotFoundException { + EntityManagerFactory emf = JpaStorageUtils.getEntityManagerFactory(); + + try { + EntityManager em = emf.createEntityManager(); + + StringBuilder tokenDelStr = new StringBuilder("DELETE FROM "); + tokenDelStr.append(Token.class.getCanonicalName()); + tokenDelStr.append(" WHERE id = :id"); + + Query tokenDel = em.createQuery(tokenDelStr.toString()); + tokenDel.setParameter("id", id); + int tokenDelCount = tokenDel.executeUpdate(); + if (tokenDelCount != 1) { + String msg = "Could not find token with id=" + id; + logger.error(msg); + throw new DocumentNotFoundException(msg); + } + } finally { + if (emf != null) { + JpaStorageUtils.releaseEntityManagerFactory(emf); + } + } + } + + private String getEncPassword(String userId, byte[] password) throws BadRequestException { + //jaxb unmarshaller already unmarshal xs:base64Binary, no need to b64 decode + //byte[] bpass = Base64.decodeBase64(accountReceived.getPassword()); + try { + SecurityUtils.validatePassword(new String(password)); + } catch (Exception e) { + throw new BadRequestException(e.getMessage()); + } + String secEncPasswd = SecurityUtils.createPasswordHash( + userId, new String(password)); + return secEncPasswd; + } +} diff --git a/services/authentication/jaxb/src/main/resources/authentication_identity_provider.xsd b/services/authentication/jaxb/src/main/resources/authentication_identity_provider.xsd index 5a62a55c8..d8e034719 100644 --- a/services/authentication/jaxb/src/main/resources/authentication_identity_provider.xsd +++ b/services/authentication/jaxb/src/main/resources/authentication_identity_provider.xsd @@ -88,6 +88,86 @@ + + + + + + Definition for creating password reset tockens. + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/services/authentication/pstore/src/main/resources/db/postgresql/authentication.sql b/services/authentication/pstore/src/main/resources/db/postgresql/authentication.sql index 7aff0f078..d424d2ae6 100644 --- a/services/authentication/pstore/src/main/resources/db/postgresql/authentication.sql +++ b/services/authentication/pstore/src/main/resources/db/postgresql/authentication.sql @@ -1,2 +1,5 @@ DROP TABLE IF EXISTS users; create table users (username varchar(128) not null, created_at timestamp not null, passwd varchar(128) not null, updated_at timestamp, primary key (username)); + +DROP TABLE IF EXISTS tokens; +create table tokens (id varchar(128) not null, account_csid varchar(128) not null, tenant_id varchar(128) not null, expire_seconds integer not null, enabled boolean not null, created_at timestamp not null, updated_at timestamp, primary key (id)); diff --git a/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/SpringAuthNContext.java b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/SpringAuthNContext.java index 1529343ac..c1dc8dd68 100644 --- a/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/SpringAuthNContext.java +++ b/services/authentication/service/src/main/java/org/collectionspace/authentication/spring/SpringAuthNContext.java @@ -58,15 +58,18 @@ public class SpringAuthNContext implements AuthNContext { */ @Override public CSpaceUser getUser() { + CSpaceUser result = null; + Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); - Object principal = authentication.getPrincipal(); - - CSpaceUser user = null; - if (principal instanceof CSpaceUser ) { - user = (CSpaceUser) principal; - } + Object principal = null; + if (authentication != null) { + principal = authentication.getPrincipal(); + if (principal instanceof CSpaceUser ) { + result = (CSpaceUser) principal; + } + } - return user; + return result; } /** diff --git a/services/common/src/main/java/org/collectionspace/services/common/EmailUtil.java b/services/common/src/main/java/org/collectionspace/services/common/EmailUtil.java new file mode 100644 index 000000000..103154f21 --- /dev/null +++ b/services/common/src/main/java/org/collectionspace/services/common/EmailUtil.java @@ -0,0 +1,109 @@ +package org.collectionspace.services.common; + +import java.util.Date; +import java.util.Properties; + +import javax.mail.Message; +import javax.mail.MessagingException; +import javax.mail.Session; +import javax.mail.Transport; +import javax.mail.internet.MimeMessage; + +import org.collectionspace.services.config.tenant.EmailConfig; + +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; + +public class EmailUtil { + final static Logger logger = LoggerFactory.getLogger(EmailUtil.class); + + private static final String MAIL_SMTP_HOST = "mail.smtp.host"; + private static final String MAIL_SMTP_PORT = "mail.smtp.port"; + private static final String MAIL_SMTP_TLS = "mail.smtp.starttls.enable"; + + private static final String MAIL_FROM = "mail.from"; + private static final String MAIL_DEBUG = "mail.debug"; + + public static void main(String [] args) { + String username = "collectionspace.lyrasis@gmail.com"; + String password = "CSpace11-GG"; + String recipient = "remillet@gmail.com"; + + Properties props = new Properties(); + +// props.setProperty("mail.smtp.host", "smtp.gmail.com"); +// props.setProperty("mail.from", "collectionspace.lyrasis@gmail.com"); +// props.setProperty("mail.smtp.starttls.enable", "true"); +// props.setProperty("mail.smtp.port", "587"); +// props.setProperty("mail.debug", "true"); + + props.setProperty(MAIL_SMTP_HOST, "smtp.gmail.com"); + props.setProperty(MAIL_SMTP_PORT, "587"); + props.setProperty(MAIL_SMTP_TLS, "true"); + props.setProperty(MAIL_FROM, "collectionspace.lyrasis@gmail.com"); + props.setProperty(MAIL_DEBUG, "true"); + + Session session = Session.getInstance(props, null); + MimeMessage msg = new MimeMessage(session); + + try { + msg.setRecipients(Message.RecipientType.TO, recipient); + msg.setSubject("JavaMail hello world example"); + msg.setSentDate(new Date()); + msg.setText("Hello, world!\n"); + + Transport transport = session.getTransport("smtp"); + + transport.connect(username, password); + transport.sendMessage(msg, msg.getAllRecipients()); + transport.close(); + } catch (Exception e) { + System.err.println(e.getMessage()); + } + } + + /* + * recipients - Is a comma separated sequence of addresses + */ + public static String sendMessage(EmailConfig emailConfig, String recipients, String message) { + String result = null; + + Properties props = new Properties(); + + props.setProperty(MAIL_SMTP_HOST, emailConfig.getSmtpConfig().getHost()); + props.setProperty(MAIL_SMTP_PORT, emailConfig.getSmtpConfig().getPort().toString()); + props.setProperty(MAIL_SMTP_TLS, + new Boolean(emailConfig.getSmtpConfig().getSmtpAuth().isEnabled()).toString()); + + props.setProperty(MAIL_FROM, emailConfig.getFrom()); + props.setProperty(MAIL_DEBUG, new Boolean(emailConfig.getSmtpConfig().isDebug()).toString()); + + Session session = Session.getInstance(props, null); + MimeMessage msg = new MimeMessage(session); + + try { + msg.setRecipients(Message.RecipientType.TO, recipients); + msg.setSubject(emailConfig.getPasswordResetConfig().getSubject()); + msg.setSentDate(new Date()); + msg.setText(message); + + Transport transport = session.getTransport("smtp"); + if (emailConfig.getSmtpConfig().getSmtpAuth().isEnabled()) { + String username = emailConfig.getSmtpConfig().getSmtpAuth().getUsername(); + String password = emailConfig.getSmtpConfig().getSmtpAuth().getPassword(); + transport.connect(username, password); + } else { + transport.connect(); + } + + transport.sendMessage(msg, msg.getAllRecipients()); + transport.close(); + } catch (MessagingException e) { + logger.error(e.getMessage(), e); + result = e.getMessage(); + } + + return result; + } + +} diff --git a/services/common/src/main/java/org/collectionspace/services/common/SecurityResourceBase.java b/services/common/src/main/java/org/collectionspace/services/common/SecurityResourceBase.java index 226a6091a..9bf20714f 100644 --- a/services/common/src/main/java/org/collectionspace/services/common/SecurityResourceBase.java +++ b/services/common/src/main/java/org/collectionspace/services/common/SecurityResourceBase.java @@ -7,6 +7,7 @@ import org.slf4j.Logger; import org.slf4j.LoggerFactory; import javax.ws.rs.PathParam; +import javax.ws.rs.core.Context; import javax.ws.rs.core.MultivaluedMap; import javax.ws.rs.core.Response; import javax.ws.rs.core.UriBuilder; @@ -31,12 +32,15 @@ public abstract class SecurityResourceBase extends AbstractCollectionSpaceResour } public Object get(String csid, Class objectClass) { + return get((UriInfo)null, csid, objectClass); + } + public Object get(UriInfo ui, String csid, Class objectClass) { logger.debug("get with csid=" + csid); ensureCSID(csid, ServiceMessages.GET_FAILED + "csid"); Object result = null; try { - ServiceContext ctx = createServiceContext((Object) null, objectClass); + ServiceContext ctx = createServiceContext((Object) null, objectClass, ui); DocumentHandler handler = createDocumentHandler(ctx); getStorageClient(ctx).get(ctx, csid, handler); result = ctx.getOutput(); @@ -49,7 +53,7 @@ public abstract class SecurityResourceBase extends AbstractCollectionSpaceResour public Object getList(UriInfo ui, Class objectClass) { try { - ServiceContext ctx = createServiceContext((Object) null, objectClass); + ServiceContext ctx = createServiceContext((Object) null, objectClass, ui); DocumentHandler handler = createDocumentHandler(ctx); MultivaluedMap queryParams = (ui != null ? ui.getQueryParameters() : null); DocumentFilter myFilter = handler.createDocumentFilter(); @@ -65,13 +69,17 @@ public abstract class SecurityResourceBase extends AbstractCollectionSpaceResour } } - public Object update(@PathParam("csid") String csid, Object theUpdate, Class objectClass) { + public Object update(String csid, Object theUpdate, Class objectClass) { + return update((UriInfo)null, csid, theUpdate, objectClass); + } + + public Object update(UriInfo ui, String csid, Object theUpdate, Class objectClass) { if (logger.isDebugEnabled()) { logger.debug("updateRole with csid=" + csid); } ensureCSID(csid, ServiceMessages.PUT_FAILED + this.getClass().getName()); try { - ServiceContext ctx = createServiceContext(theUpdate, objectClass); + ServiceContext ctx = createServiceContext(theUpdate, objectClass, ui); DocumentHandler handler = createDocumentHandler(ctx); getStorageClient(ctx).update(ctx, csid, handler); return ctx.getOutput(); diff --git a/services/common/src/main/java/org/collectionspace/services/common/ServiceMessages.java b/services/common/src/main/java/org/collectionspace/services/common/ServiceMessages.java index 630bff3c4..86770ebdf 100644 --- a/services/common/src/main/java/org/collectionspace/services/common/ServiceMessages.java +++ b/services/common/src/main/java/org/collectionspace/services/common/ServiceMessages.java @@ -56,6 +56,7 @@ public class ServiceMessages { public static final String SEARCH_FAILED = "Search request" + FAILED; public static final String AUTH_REFS_FAILED = "Authority references request" + FAILED; + public static final String PASSWORD_RESET_REQUEST_FAILED = "Password reset request" + FAILED; public static final String UNKNOWN_ERROR_MSG = "Unknown error "; public static final String VALIDATION_FAILURE = "Validation failure "; public static final String MISSING_CSID = "missing csid"; diff --git a/services/common/src/main/java/org/collectionspace/services/common/authorization_mgt/AuthorizationCommon.java b/services/common/src/main/java/org/collectionspace/services/common/authorization_mgt/AuthorizationCommon.java index 638125f74..c9376c268 100644 --- a/services/common/src/main/java/org/collectionspace/services/common/authorization_mgt/AuthorizationCommon.java +++ b/services/common/src/main/java/org/collectionspace/services/common/authorization_mgt/AuthorizationCommon.java @@ -1,5 +1,7 @@ package org.collectionspace.services.common.authorization_mgt; +import java.security.MessageDigest; +import java.security.NoSuchAlgorithmException; import java.sql.Connection; import java.sql.PreparedStatement; import java.sql.ResultSet; @@ -18,6 +20,9 @@ import javax.persistence.EntityManager; import javax.persistence.EntityManagerFactory; import org.collectionspace.authentication.AuthN; +import org.collectionspace.services.account.AccountListItem; + +import org.collectionspace.services.authentication.Token; import org.collectionspace.services.authorization.AuthZ; import org.collectionspace.services.authorization.CSpaceAction; import org.collectionspace.services.authorization.PermissionException; @@ -32,6 +37,7 @@ import org.collectionspace.services.authorization.perms.ActionType; import org.collectionspace.services.authorization.perms.EffectType; import org.collectionspace.services.authorization.perms.Permission; import org.collectionspace.services.authorization.perms.PermissionAction; + import org.collectionspace.services.client.Profiler; import org.collectionspace.services.client.RoleClient; import org.collectionspace.services.client.workflow.WorkflowClient; @@ -44,6 +50,8 @@ import org.collectionspace.services.common.storage.DatabaseProductType; import org.collectionspace.services.common.storage.JDBCTools; import org.collectionspace.services.common.storage.jpa.JpaStorageUtils; import org.collectionspace.services.config.service.ServiceBindingType; +import org.collectionspace.services.config.tenant.EmailConfig; +import org.collectionspace.services.config.tenant.PasswordResetConfig; import org.collectionspace.services.config.tenant.TenantBindingType; import org.collectionspace.services.lifecycle.Lifecycle; import org.collectionspace.services.lifecycle.TransitionDef; @@ -58,6 +66,15 @@ import org.springframework.security.acls.model.AlreadyExistsException; public class AuthorizationCommon { final public static String REFRESH_AUTZ_PROP = "refreshAuthZOnStartup"; + + // + // For token generation and password reset + // + final private static String DEFAULT_PASSWORD_RESET_EMAIL_MESSAGE = "Hello {{greeting}},\n\r\n\rYou've started the process to reset your CollectionSpace account password. To finish resetting your password, go to the Reset Password page {{link}} on CollectionSpace.\n\r\n\rIf clicking the link doesn't work, copy and paste the following link into your browser address bar and click Go.\n\r\n\r{{link}}\n\r Thanks,\n\r\n\r CollectionSpace Administrator\n\r\n\rPlease do not reply to this email. This mailbox is not monitored and you will not receive a response. For assistance, contact your CollectionSpace Administrator directly."; + final private static String tokensalt = "74102328UserDetailsReset"; + final private static int TIME_SCALAR = 100000; + private static final String DEFAULT_PASSWORD_RESET_EMAIL_SUBJECT = "Password reset for CollectionSpace account"; + // // ActionGroup labels/constants // @@ -126,6 +143,7 @@ public class AuthorizationCommon { final private static String GET_TENANT_MGR_ROLE_SQL = "SELECT csid from roles WHERE tenant_id='" + AuthN.ALL_TENANTS_MANAGER_TENANT_ID + "' and rolename=?"; + public static Role getRole(String tenantId, String displayName) { Role role = null; @@ -1162,5 +1180,88 @@ public class AuthorizationCommon { } } + + public static boolean hasTokenExpired(EmailConfig emailConfig, Token token) throws NoSuchAlgorithmException { + boolean result = false; + + int maxConfigSeconds = emailConfig.getPasswordResetConfig().getTokenExpirationSeconds().intValue(); + int maxTokenSeconds = token.getExpireSeconds().intValue(); + + long createdTime = token.getCreatedAtItem().getTime(); + long configExpirationTime = createdTime + maxConfigSeconds * 1000; // the current tenant config for how long a token stays valid + long tokenDefinedExirationTime = createdTime + maxTokenSeconds * 1000; // the tenant config for how long a token stays valid when the token was created. + + if (configExpirationTime != tokenDefinedExirationTime) { + String msg = String.format("The configured expiration time for the token = '%s' changed from when the token was created.", + token.getId()); + logger.warn(msg); + } + // + // Note: the current tenant bindings config for expiration takes precedence over the config used to create the token. + // + if (System.currentTimeMillis() >= configExpirationTime) { + result = true; + } + + return result; + } + + /* + * Validate that the password reset configuration is correct. + */ + private static String validatePasswordResetConfig(PasswordResetConfig passwordResetConfig) { + String result = null; + + if (passwordResetConfig != null) { + result = passwordResetConfig.getMessage(); + if (result == null || result.length() == 0) { + result = DEFAULT_PASSWORD_RESET_EMAIL_MESSAGE; + logger.warn("Could not find a password reset message in the tenant's configuration. Using the default one"); + } + + if (result.contains("{{link}}") == false) { + logger.warn("The tenant's password reset message does not contain a required '{{link}}' marker."); + result = null; + } + + if (passwordResetConfig.getLoginpage() == null || passwordResetConfig.getLoginpage().trim().isEmpty()) { + logger.warn("The tenant's password reset configuration is missing a 'loginpage' value. It should be set to something like '/collectionspace/ui/core/html/index.html'."); + result = null; + } + + String subject = passwordResetConfig.getSubject(); + if (subject == null || subject.trim().isEmpty()) { + passwordResetConfig.setSubject(DEFAULT_PASSWORD_RESET_EMAIL_SUBJECT); + } + } + + return result; + } + + /* + * Generate a password reset message. Embeds an authorization token to reset a user's password. + */ + public static String generatePasswordResetEmailMessage(EmailConfig emailConfig, AccountListItem accountListItem, Token token) throws Exception { + String result = null; + + result = validatePasswordResetConfig(emailConfig.getPasswordResetConfig()); + if (result == null) { + String errMsg = String.format("The password reset configuration for the tenant ID='%s' is missing or malformed. Could not initiate a password reset for user ID='%s. See the log files for more details.", + token.getTenantId(), accountListItem.getEmail()); + throw new Exception(errMsg); + } + + String link = emailConfig.getBaseurl() + emailConfig.getPasswordResetConfig().getLoginpage() + "?token=" + token.getId(); + result = result.replaceAll("\\{\\{link\\}\\}", link); + + if (result.contains("{{greeting}}")) { + String greeting = accountListItem.getScreenName(); + result = result.replaceAll("\\{\\{greeting\\}\\}", greeting); + result = result.replaceAll("\\\\n", "\\\n"); + result = result.replaceAll("\\\\r", "\\\r"); + } + + return result; + } } diff --git a/services/common/src/main/java/org/collectionspace/services/common/security/SecurityContextImpl.java b/services/common/src/main/java/org/collectionspace/services/common/security/SecurityContextImpl.java index 057b28bb0..0d8b81ce7 100644 --- a/services/common/src/main/java/org/collectionspace/services/common/security/SecurityContextImpl.java +++ b/services/common/src/main/java/org/collectionspace/services/common/security/SecurityContextImpl.java @@ -54,20 +54,21 @@ public class SecurityContextImpl implements SecurityContext { // If anonymous access is being attempted, then a tenant ID needs to be set as a query param // if (uriInfo == null) { - String errMsg = "Anonymous access attempted without a valid tenant ID query paramter. A null 'UriInfo' instance was passed into the service context constructor."; + String errMsg = "Anonymous access attempted without a valid tenant ID query or path paramter. Error: A null 'UriInfo' instance was passed into the service context constructor."; logger.error(errMsg); throw new UnauthorizedException(errMsg); } -// String tenantId = uriInfo.getQueryParameters().getFirst(AuthNContext.TENANT_ID_QUERY_PARAM); - String tenantId = uriInfo.getPathParameters().getFirst(AuthN.TENANT_ID_PATH_PARAM); - if (tenantId == null) { - String errMsg = String.format("Anonymous access to '%s' attempted without a valid tenant ID query paramter.", + String tenantIdQueryParam = uriInfo.getQueryParameters().getFirst(AuthN.TENANT_ID_QUERY_PARAM); + String tenantPathParam = uriInfo.getPathParameters().getFirst(AuthN.TENANT_ID_PATH_PARAM); + if (tenantIdQueryParam == null && tenantPathParam == null) { + String errMsg = String.format("Anonymous access to '%s' attempted without a valid tenant ID query or path paramter.", uriInfo.getPath()); logger.error(errMsg); throw new UnauthorizedException(errMsg); } - result = tenantId; + + result = tenantIdQueryParam != null ? tenantIdQueryParam : tenantPathParam; // If both have value, user the query param (not path) value } return result; diff --git a/services/common/src/main/java/org/collectionspace/services/common/security/SecurityInterceptor.java b/services/common/src/main/java/org/collectionspace/services/common/security/SecurityInterceptor.java index 30182024f..c996233d8 100644 --- a/services/common/src/main/java/org/collectionspace/services/common/security/SecurityInterceptor.java +++ b/services/common/src/main/java/org/collectionspace/services/common/security/SecurityInterceptor.java @@ -84,6 +84,8 @@ public class SecurityInterceptor implements PreProcessInterceptor, PostProcessIn /** The Constant logger. */ private static final Logger logger = LoggerFactory.getLogger(SecurityInterceptor.class); private static final String ACCOUNT_PERMISSIONS = "accounts/*/accountperms"; + private static final String PASSWORD_RESET = "accounts/requestpasswordreset"; + private static final String PROCESS_PASSWORD_RESET = "accounts/processpasswordreset"; private static final String NUXEO_ADMIN = null; // // Use this thread specific member instance to hold our login context with Nuxeo @@ -99,6 +101,11 @@ public class SecurityInterceptor implements PreProcessInterceptor, PostProcessIn private boolean isAnonymousRequest(HttpRequest request, ResourceMethodInvoker resourceMethodInvoker) { boolean result = false; + String resName = SecurityUtils.getResourceName(request.getUri()); + if (resName.equalsIgnoreCase(PASSWORD_RESET) || resName.equals(PROCESS_PASSWORD_RESET)) { + return true; + } + Class resourceClass = resourceMethodInvoker.getResourceClass(); try { CollectionSpaceResource resourceInstance = (CollectionSpaceResource)resourceClass.newInstance(); @@ -112,6 +119,23 @@ public class SecurityInterceptor implements PreProcessInterceptor, PostProcessIn return result; } + /* + * Check to see if the resource required authorization to access + * + */ + private boolean requiresAuthorization(String resName) { + boolean result = true; + // + // All active users are allowed to see the *their* (we enforce this) current list of permissions. If this is not + // the request, then we'll do a full AuthZ check. + // + if (resName.equalsIgnoreCase(ACCOUNT_PERMISSIONS) == true) { + result = false; + } + + return result; + } + /* (non-Javadoc) * @see org.jboss.resteasy.spi.interception.PreProcessInterceptor#preProcess(org.jboss.resteasy.spi.HttpRequest, org.jboss.resteasy.core.ResourceMethod) */ @@ -158,11 +182,7 @@ public class SecurityInterceptor implements PreProcessInterceptor, PostProcessIn // checkActive(); - // - // All active users are allowed to see the *their* (we enforce this) current list of permissions. If this is not - // the request, then we'll do a full AuthZ check. - // - if (resName.equalsIgnoreCase(ACCOUNT_PERMISSIONS) != true) { //see comment immediately above + if (requiresAuthorization(resName) == true) { //see comment immediately above AuthZ authZ = AuthZ.get(); CSpaceResource res = new URIResourceImpl(AuthN.get().getCurrentTenantId(), resName, httpMethod); if (authZ.isAccessAllowed(res) == false) { diff --git a/services/config/src/main/resources/instance1.xml b/services/config/src/main/resources/instance1.xml deleted file mode 100644 index 88781295a..000000000 --- a/services/config/src/main/resources/instance1.xml +++ /dev/null @@ -1,453 +0,0 @@ - - - 123 - 123 - 123 - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - - 123 - - - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - - - 123 - - - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - - - 123 - - - 123 - - 123 - - 123 - - - - 123 - 123 - 123 - 123 - 123 - 123 - 123 - 123 - 123 - 123 - - - 123 - 123 - 123 - 123 - 123 - 123 - 123 - 123 - 123 - 123 - - - 123 - 123 - 123 - 123 - 123 - 123 - 123 - 123 - 123 - 123 - - - 123 - - 123 - 123 - 123 - 123 - - 123 - 123 - 123 - 123 - 123 - 123 - 123 - - 123 - - 123 - 123 - 123 - 123 - - - 123 - 123 - 123 - 123 - - - 123 - 123 - 123 - 123 - - - - - - - 123 - 123 - 123 - - - 123 - 123 - 123 - - - 123 - 123 - 123 - - - 123 - 123 - 123 - 123 - 123 - - 123 - - - 123 - 123 - 123 - 123 - - - 123 - 123 - 123 - 123 - - - 123 - 123 - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - 123 - - - 123 - 123 - 123 - 123 - - - 123 - 123 - 123 - 123 - - - 123 - 123 - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - 123 - - - 123 - 123 - 123 - 123 - - - 123 - 123 - 123 - 123 - - - 123 - 123 - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - - - 123 - 123 - - - 123 - 123 - - - 123 - 123 - - - diff --git a/services/config/src/main/resources/tenant.xsd b/services/config/src/main/resources/tenant.xsd index 177dbaed4..7e81de2e2 100644 --- a/services/config/src/main/resources/tenant.xsd +++ b/services/config/src/main/resources/tenant.xsd @@ -50,6 +50,7 @@ + @@ -98,6 +99,53 @@ + + + Configuration for how a tenant sends email notifications + + + + + + + + + + + + SMTP config for sending emails. + + + + + + + + + + + + SMTP authentication config for sending emails. + + + + + + + + + + + Config for password resets + + + + + + + + + -- 2.47.3