diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ff5896..8ed3512 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added - Add polling to unlock accounts after a configurable cooldown period (AUTHLIB-180) +- Email domain validation for LDAP accounts (AUTHLIB-178) ## [4.3.0] - 2026-09-16 diff --git a/authentication_lib/src/main/java/org/octri/authentication/server/controller/UserController.java b/authentication_lib/src/main/java/org/octri/authentication/server/controller/UserController.java index f8db544..ef3c177 100644 --- a/authentication_lib/src/main/java/org/octri/authentication/server/controller/UserController.java +++ b/authentication_lib/src/main/java/org/octri/authentication/server/controller/UserController.java @@ -40,6 +40,7 @@ import org.springframework.ui.ModelMap; import org.springframework.util.Assert; import org.springframework.validation.BindingResult; +import org.springframework.validation.FieldError; import org.springframework.web.bind.WebDataBinder; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.InitBinder; @@ -155,10 +156,10 @@ public ModelAndView create(@ModelAttribute User user, BindingResult bindingResul setUserFormAttributes(model, user); var errorView = new ModelAndView(FORM_TEMPLATE, model); - var validationResult = validateUser(user); - if (validationResult.size() > 0) { + var errors = validateUser(user); + if (errors.size() > 0) { model.addAttribute("error", true); - model.addAttribute("errors", validationUtils.getErrors(user, validationResult)); + model.addAttribute("errors", errors); return errorView; } @@ -255,10 +256,10 @@ public ModelAndView update(@ModelAttribute User user, BindingResult bindingResul setUserFormAttributes(model, user); var errorView = new ModelAndView(FORM_TEMPLATE, model); - var validationResult = validateUser(user); - if (validationResult.size() > 0) { + var errors = validateUser(user); + if (errors.size() > 0) { model.addAttribute("error", true); - model.addAttribute("errors", validationUtils.getErrors(user, validationResult)); + model.addAttribute("errors", errors); return errorView; } @@ -340,9 +341,9 @@ private String getRoleStyleAttribute() { * Validates the user entity and returns any violations found. * * @param user - * @return any constraint violations found when validating the user. May be empty. + * @return any field errors caused by constraint violations found when validating the user. May be empty. */ - private Set> validateUser(User user) { + private List validateUser(User user) { Boolean emailRequired = authenticationProperties.getEmailRequired(); Set> validationResult = !emailRequired @@ -350,7 +351,26 @@ private Set> validateUser(User user) { ? validator.validate(user, Default.class) : validator.validate(user, Emailable.class); - return validationResult; + List errors = validationUtils.getErrors(user, validationResult); + if (invalidEmailDomain(user)) { + errors.add(new FieldError(User.class.getName(), "email", + "Email must end with @" + getLdapEmailDomain() + " for LDAP accounts")); + } + return errors; + } + + /** + * Checks that an LDAP user's email address belongs to the configured LDAP email domain. Blank emails are left to + * bean validation. + * + * @param user + * @return an error for the email field if the domain does not match + */ + private Boolean invalidEmailDomain(User user) { + var ldapEmailDomain = getLdapEmailDomain(); + return !StringUtils.isBlank(ldapEmailDomain) + && !StringUtils.isBlank(user.getEmail()) + && !SecurityHelper.hasEmailDomain(user, ldapEmailDomain); } /** diff --git a/authentication_ui_bootstrap5/src/main/resources/static/assets/js/authlib.js b/authentication_ui_bootstrap5/src/main/resources/static/assets/js/authlib.js index 2c09a59..4aaab8e 100644 --- a/authentication_ui_bootstrap5/src/main/resources/static/assets/js/authlib.js +++ b/authentication_ui_bootstrap5/src/main/resources/static/assets/js/authlib.js @@ -156,6 +156,37 @@ } } + /** + * Validates that email input matches the configured LDAP domain, if the authenticationMethod + * is set to LDAP. + * + * The browser's built-in checks (required, format, length) take precedence. + * @returns true if the email is valid + */ + function validateLdapEmail(emailInput, authenticationMethod, ldapEmailDomain, feedbackElement, defaultMessage) { + // Clear any previous domain error; empty input is left to the built-in required check + emailInput.setCustomValidity(''); + + const email = emailInput.value.trim().toLowerCase(); + const domainMatches = authenticationMethod !== 'LDAP' + || email === '' + || email.endsWith(ldapEmailDomain.toLowerCase()); + let errorMessage = ''; + if (!emailInput.validity.valid) { + errorMessage = defaultMessage; + } else if (!domainMatches) { + errorMessage = 'Email must end with ' + ldapEmailDomain + ' for LDAP accounts'; + } + + if (errorMessage !== '') { + emailInput.setCustomValidity(errorMessage); + } + if (feedbackElement) { + feedbackElement.textContent = errorMessage; + } + return errorMessage === ''; + } + /** * Displays an LDAP search error message. */ @@ -279,74 +310,102 @@ usernameInput.addEventListener('input', searchHandler); } - }); - // Enable LDAP lookup when appropriate authentication method is selected - const authenticationMethodInput = document.getElementById('authentication_method'); - const ldapLookupButton = document.getElementById('ldap_lookup'); - const enableLdapSearch = authenticationMethodInput && authenticationMethodInput.value && - authenticationMethodInput.value !== 'TABLE_BASED'; - - if (ldapLookupButton) { - ldapLookupButton.disabled = !enableLdapSearch; - authenticationMethodInput.addEventListener('change', function(_evt) { - const enableLdapSearch = this.value && this.value !== 'TABLE_BASED'; - ldapLookupButton.disabled = !enableLdapSearch; - }); - - // Look up by username in LDAP and prepopulate user fields - ldapLookupButton.addEventListener('click', function (evt) { - evt.preventDefault(); - hideLdapError(); - const usernameInput = document.getElementById('username'); - const username = usernameInput ? usernameInput.value : null; - - if (!username) { - return; - } + const authenticationMethodInput = document.getElementById('authentication_method'); + const emailInput = document.getElementById('email'); - const csrfTokenInput = document.querySelector('input[name="_csrf"]'); - const csrfToken = csrfTokenInput ? csrfTokenInput.value : null; + // Enable LDAP lookup when appropriate authentication method is selected + const ldapLookupButton = document.getElementById('ldap_lookup'); + if (ldapLookupButton && authenticationMethodInput) { + authenticationMethodInput.addEventListener('change', () => { + ldapLookupButton.disabled = authenticationMethodInput.value !== 'LDAP'; + }); + authenticationMethodInput.dispatchEvent(new Event('change')); - const ldapLookupEndpoint = getContextPath() + 'admin/user/ldapLookup'; - const requestBody = new FormData(); - requestBody.set('username', username); + // Look up by username in LDAP and prepopulate user fields + ldapLookupButton.addEventListener('click', function (evt) { + evt.preventDefault(); + hideLdapError(); + const username = usernameInput ? usernameInput.value : null; - fetch(ldapLookupEndpoint, { - method: 'post', - body: requestBody, - headers: { - 'X-CSRF-TOKEN': csrfToken + if (!username) { + return; } - }) - .then(response => { - if (!response.ok) { - const errorMessage = 'Search request failed'; - showLdapError(errorMessage); - throw new Error(errorMessage); - } - return response.json(); - }) - .then(jsonData => { - if (jsonData.ldapLookupError) { - showLdapError(jsonData.ldapLookupError); - } else { - document.getElementById('first_name').value = jsonData.firstName; - document.getElementById('last_name').value = jsonData.lastName; - document.getElementById('email').value = jsonData.email; - document.getElementById('institution').value = jsonData.institution; + + const csrfTokenInput = document.querySelector('input[name="_csrf"]'); + const csrfToken = csrfTokenInput ? csrfTokenInput.value : null; + + const ldapLookupEndpoint = getContextPath() + 'admin/user/ldapLookup'; + const requestBody = new FormData(); + requestBody.set('username', username); + + fetch(ldapLookupEndpoint, { + method: 'post', + body: requestBody, + headers: { + 'X-CSRF-TOKEN': csrfToken } }) - .catch(reason => console.error(reason)); - }); - } + .then(response => { + if (!response.ok) { + const errorMessage = 'Search request failed'; + showLdapError(errorMessage); + throw new Error(errorMessage); + } + return response.json(); + }) + .then(jsonData => { + if (jsonData.ldapLookupError) { + showLdapError(jsonData.ldapLookupError); + } else { + document.getElementById('first_name').value = jsonData.firstName; + document.getElementById('last_name').value = jsonData.lastName; + document.getElementById('email').value = jsonData.email; + document.getElementById('institution').value = jsonData.institution; + document.getElementById('email').dispatchEvent(new Event('change')); + } + }) + .catch(reason => console.error(reason)); + }); + } - // Display any server validation errors included in hidden elements - const serverValidationErrors = document.querySelectorAll('[data-error]'); - for (const serverError of serverValidationErrors) { - const inputElement = document.getElementById(serverError.dataset.field); - if (inputElement) { - inputElement.classList.add('is-invalid'); + // For LDAP users, require the email address to belong to the configured LDAP domain. + if (ldapEmailDomain && emailInput && authenticationMethodInput) { + const emailFeedback = findErrorDivForElement(emailInput); + const defaultEmailFeedback = emailFeedback ? emailFeedback.textContent : ''; + const checkEmailDomain = function () { + return validateLdapEmail(emailInput, authenticationMethodInput.value, ldapEmailDomain, + emailFeedback, defaultEmailFeedback); + }; + + // Check email domain when input changes + emailInput.addEventListener('input', () => { + emailInput.classList.toggle('is-invalid', !checkEmailDomain()); + }); + emailInput.addEventListener('change', () => { + emailInput.classList.toggle('is-invalid', !checkEmailDomain()); + }); + // Update validation when the authentication method changes + authenticationMethodInput.addEventListener('change', () => { + const valid = checkEmailDomain(); + emailInput.classList.toggle('is-invalid', !valid && emailInput.value.trim() !== ''); + }); + + // Set the initial validity without clearing any server-side error styling + checkEmailDomain(); } - } + + // Display any server validation errors included in hidden elements + const serverValidationErrors = document.querySelectorAll('[data-error]'); + for (const serverError of serverValidationErrors) { + const inputElement = document.getElementById(serverError.dataset.field); + if (inputElement) { + inputElement.classList.add('is-invalid'); + const feedbackElement = findErrorDivForElement(inputElement); + if (feedbackElement) { + feedbackElement.textContent = serverError.dataset.message; + } + } + } + }); })();