ESUserDetailsService.java
/*
* Copyright (c) 2007-2026 MetaSolutions AB
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* 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.
*/
package org.entrystore.rest.springboot.security;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.entrystore.Entry;
import org.entrystore.GraphType;
import org.entrystore.PrincipalManager;
import org.entrystore.User;
import org.entrystore.rest.springboot.model.auth.SessionInfo;
import org.entrystore.rest.springboot.model.auth.UserAuthRole;
import org.entrystore.rest.springboot.service.UserService;
import org.entrystore.rest.springboot.util.PrincipalManagerUtil;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.net.URI;
/**
* ESUserDetailsService is a Spring Security {@link UserDetailsService} implementation
* responsible for loading user-specific data during authentication. This class is
* tightly coupled with the security mechanism of the application and provides
* a way to retrieve and map user details from the application's data store.
* <p>
* The `loadUserByUsername` method will be called during form login to check if
* a given logging-in user has correct credentials.
* <p>
* It performs the following primary functionalities:
* - Fetches the user details from the data store (PrincipalManager) based on the username.
* - Maps the retrieved user information to a Spring Security compatible {@link UserDetails} object.
* <p>
* Dependencies:
* - {@link PrincipalManager}: Manages user and principal data.
* - {@link UserService}: Provides utility methods for user-related operations including
* admin role checks.
* <p>
* Exception Handling:
* - Throws {@link UsernameNotFoundException} if the user is not found in the data store
* or their credentials are invalid.
*
*/
@Slf4j
@RequiredArgsConstructor
@Service
public class ESUserDetailsService implements UserDetailsService {
private final PrincipalManager pm;
private final UserService userService;
@Override
public UserDetails loadUserByUsername(String username) throws UsernameNotFoundException {
return PrincipalManagerUtil.runAsAdmin(pm, () -> {
Entry userEntry;
if (username.contains("/resource/")) {
userEntry = pm.getPrincipalEntry(pm.getPrincipalName(URI.create(username)));
} else {
userEntry = pm.getPrincipalEntry(username);
}
if (userEntry != null && GraphType.User.equals(userEntry.getGraphType())) {
User user = ((User) userEntry.getResource());
if (user.getSaltedHashedSecret() != null) {
SessionInfo.SessionInfoBuilder sessionInfo = SessionInfo.builder()
.userName(username.toLowerCase());
return mapESUserToUserSessionDetails(user, sessionInfo.build());
} else {
log.error("No secret found for user: '{}'", username);
}
} else {
log.info("User Entry not found for username: '{}'", username);
}
throw new UsernameNotFoundException("User not found " + username);
});
}
/**
* Loads Entrystore User by username using PrincipalManager
*
* @param username User entity name to be loaded
* @return Entrystore User or null if not found
*/
public User loadUser(String username) {
return PrincipalManagerUtil.runAsAdmin(pm, () -> {
Entry userEntry = pm.getPrincipalEntry(username);
if (userEntry != null && GraphType.User.equals(userEntry.getGraphType())) {
return ((User) userEntry.getResource());
}
log.info("User Entry not found for username: '{}'", username);
return null;
});
}
/**
* Creates a new EntryStore User with the given principal name.
* Used by SSO login handlers (CAS, SAML, OIDC) for auto-provisioning.
*
* @param username principal name for the new user
* @return the created User
* @throws IllegalStateException if the entry cannot be created or the principal name is already in use
*/
public User createUser(String username) {
return PrincipalManagerUtil.runAsAdmin(pm, () -> {
Entry entry = null;
try {
entry = pm.createResource(null, GraphType.User, null, null);
if (entry == null) {
throw new IllegalStateException("createResource returned null when provisioning user '%s'".formatted(username));
}
boolean nameSet = pm.setPrincipalName(entry.getResourceURI(), username);
if (!nameSet) {
log.warn("Principal name '{}' already in use — removing orphaned entry to prevent account takeover", username);
removeOrphanedEntry(entry, username);
entry = null; // prevent double-removal in the catch block
throw new IllegalStateException("Principal name '%s' already in use".formatted(username));
}
User u = (User) entry.getResource();
log.info("Created user '{}'", u.getURI());
return u;
} catch (Throwable t) {
// Any failure after createResource succeeded leaves an orphaned nameless User entry.
// Remove it unless it was already removed (name-collision branch above).
if (entry != null) {
removeOrphanedEntry(entry, username);
}
throw t;
}
});
}
private void removeOrphanedEntry(Entry entry, String username) {
try {
pm.remove(entry.getEntryURI());
} catch (Exception cleanup) {
log.error("Failed to remove orphaned user entry {} for '{}' during cleanup",
entry.getEntryURI(), username, cleanup);
}
}
private UserDetails mapESUserToUserSessionDetails(User user, SessionInfo sessionInfo) {
UserDetails userDetails = org.springframework.security.core.userdetails.User
.withUsername(user.getEntry().getResourceURI().toString())
.password(user.getSaltedHashedSecret())
.disabled(user.isDisabled())
.roles(userService.isAdmin(user) ? UserAuthRole.ADMIN.name() : UserAuthRole.USER.name())
.build();
return new ESUserSessionDetails(userDetails, user, sessionInfo);
}
}