Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I couldn't quickly make sense of how to implement a new validator, but the ConstraintViolations were ultimately converted to FieldError's, so inserting a FieldError in this context seemed pretty safe.

Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down Expand Up @@ -340,17 +341,36 @@ 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<ConstraintViolation<User>> validateUser(User user) {
private List<FieldError> validateUser(User user) {
Boolean emailRequired = authenticationProperties.getEmailRequired();

Set<ConstraintViolation<User>> validationResult = !emailRequired
&& StringUtils.isBlank(user.getEmail())
? validator.validate(user, Default.class)
: validator.validate(user, Emailable.class);

return validationResult;
List<FieldError> 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);
}

/**
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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.
*/
Expand Down Expand Up @@ -279,74 +310,102 @@

usernameInput.addEventListener('input', searchHandler);
}
});

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Most of the code was moved inside the onLoad handler, largely to reduce the number of redundant variable declarations across the two contexts.


// 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'));

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ensures that the validation is run after the LDAP search button is used, which effectively clears the previous feedback message.

}
})
.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;
}
Comment on lines +404 to +407

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Updates the feedback message with server error messages.

}
}
});
})();
Loading