diff --git a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AbstractAccountsManager.java b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AbstractAccountsManager.java index 757bbd5f..af64d52c 100644 --- a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AbstractAccountsManager.java +++ b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AbstractAccountsManager.java @@ -29,6 +29,25 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; +/** + * Abstract implementation of {@link AccountManager} providing common account + * management logic. + *

+ * This class ensures thread-safe user retrieval and creation by using a + * {@link ReadWriteLock}. Implementations must define specific storage + * operations for finding and creating users. + *

+ * + *

+ * When a new user is created, an {@link AccountCreated} event is published + * using the {@link ApplicationEventPublisher} to notify the system of the new + * account. + *

+ * + * @see AccountManager + * @see org.georchestra.gateway.security.exceptions.DuplicatedEmailFoundException + * @see org.georchestra.security.model.GeorchestraUser + */ @RequiredArgsConstructor public abstract class AbstractAccountsManager implements AccountManager { @@ -36,11 +55,38 @@ public abstract class AbstractAccountsManager implements AccountManager { protected final ReadWriteLock lock = new ReentrantReadWriteLock(); + /** + * Retrieves an existing stored user corresponding to {@code mappedUser} or + * creates a new one if not found. + *

+ * This method ensures thread safety by acquiring a read lock when searching for + * the user and a write lock when creating a new user. + *

+ *

+ * If a new user is created, an {@link AccountCreated} event is published. + *

+ * + * @param mappedUser the user to find or create + * @return the existing or newly created {@link GeorchestraUser} + * @throws DuplicatedEmailFoundException if a user with the same email already + * exists + */ @Override public GeorchestraUser getOrCreate(@NonNull GeorchestraUser mappedUser) throws DuplicatedEmailFoundException { return find(mappedUser).orElseGet(() -> createIfMissing(mappedUser)); } + /** + * Retrieves the stored user corresponding to {@code mappedUser}, if it exists. + *

+ * This method is thread-safe and acquires a read lock to ensure consistent + * reads. + *

+ * + * @param mappedUser the user to search for + * @return an {@link Optional} containing the found user, or an empty + * {@link Optional} if not found + */ public Optional find(GeorchestraUser mappedUser) { lock.readLock().lock(); try { @@ -50,34 +96,86 @@ public Optional find(GeorchestraUser mappedUser) { } } + /** + * Internal method to search for a user based on OAuth2 credentials or username. + *

+ * This method is called within {@link #find(GeorchestraUser)} and does not + * apply any locking. + *

+ * + * @param mappedUser the user to search for + * @return an {@link Optional} containing the found user, or an empty + * {@link Optional} if not found + */ protected Optional findInternal(GeorchestraUser mappedUser) { - if ((null != mappedUser.getOAuth2Provider()) && (null != mappedUser.getOAuth2Uid())) { + if (mappedUser.getOAuth2Provider() != null && mappedUser.getOAuth2Uid() != null) { return findByOAuth2Uid(mappedUser.getOAuth2Provider(), mappedUser.getOAuth2Uid()); } return findByUsername(mappedUser.getUsername()); } + /** + * Creates a user if it does not already exist in the repository. + *

+ * This method acquires a write lock to ensure only one thread creates a user at + * a time. If a user is created, an {@link AccountCreated} event is published. + *

+ * + * @param mapped the user to create if missing + * @return the existing or newly created {@link GeorchestraUser} + * @throws DuplicatedEmailFoundException if a user with the same email already + * exists + */ protected GeorchestraUser createIfMissing(GeorchestraUser mapped) throws DuplicatedEmailFoundException { lock.writeLock().lock(); try { GeorchestraUser existing = findInternal(mapped).orElse(null); - if (null == existing) { + if (existing == null) { createInternal(mapped); existing = findInternal(mapped).orElseThrow(() -> new IllegalStateException( - "User " + mapped.getUsername() + " not found right after creation")); + "User " + mapped.getUsername() + " not found immediately after creation")); eventPublisher.publishEvent(new AccountCreated(existing)); } return existing; - } finally { lock.writeLock().unlock(); } } + /** + * Finds a user by their OAuth2 provider and unique identifier. + *

+ * Implementations must provide a concrete method for retrieving users from + * storage. + *

+ * + * @param oauth2Provider the OAuth2 provider (e.g., Google, GitHub) + * @param oauth2Uid the unique identifier assigned by the OAuth2 provider + * @return an {@link Optional} containing the found user, or an empty + * {@link Optional} if not found + */ protected abstract Optional findByOAuth2Uid(String oauth2Provider, String oauth2Uid); + /** + * Finds a user by their username. + *

+ * Implementations must provide a concrete method for retrieving users from + * storage. + *

+ * + * @param username the username to search for + * @return an {@link Optional} containing the found user, or an empty + * {@link Optional} if not found + */ protected abstract Optional findByUsername(String username); + /** + * Creates a new user in the repository. + *

+ * Implementations must define how users are persisted in the storage system. + *

+ * + * @param mapped the user to create + */ protected abstract void createInternal(GeorchestraUser mapped); - -} +} \ No newline at end of file diff --git a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AccountCreated.java b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AccountCreated.java index ee536f54..14f695f3 100644 --- a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AccountCreated.java +++ b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AccountCreated.java @@ -24,10 +24,23 @@ import lombok.Value; /** - * Application event published when a new account has been created + * Event published when a new {@link GeorchestraUser} account is created. + *

+ * This event is triggered whenever a new user is successfully registered in the + * system. It can be used to listen for account creation and trigger additional + * actions such as logging, notifications, or audits. + *

+ * + *

+ * This class is immutable and thread-safe, though the attached + * {@link GeorchestraUser} is mutable, so make sure not to modify it. + *

+ * + * @see GeorchestraUser */ @Value public class AccountCreated { + /** The newly created user account. */ private @NonNull GeorchestraUser user; -} +} \ No newline at end of file diff --git a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AccountManager.java b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AccountManager.java index 02d1b897..90e79a75 100644 --- a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AccountManager.java +++ b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/AccountManager.java @@ -27,35 +27,48 @@ import org.springframework.security.core.Authentication; /** + * Manages the retrieval and creation of stored {@link GeorchestraUser + * Georchestra users}. + *

+ * This interface provides methods to look up an existing user or create a new + * one if it does not exist. Implementations of this interface should ensure + * that user accounts are correctly managed within the system and that necessary + * events are published when a new user is created. + *

+ * * @see CreateAccountUserCustomizer * @see ResolveGeorchestraUserGlobalFilter */ public interface AccountManager { /** - * Finds the stored user that belongs to the {@code mappedUser} if it exists + * Retrieves the stored user corresponding to the given {@code mappedUser}, if + * it exists. * - * @param mappedUser the user {@link ResolveGeorchestraUserGlobalFilter} - * resolved by calling + * @param mappedUser the user resolved by + * {@link ResolveGeorchestraUserGlobalFilter}, obtained + * through a call to * {@link GeorchestraUserMapper#resolve(Authentication)} - * @return the stored version of the user if it exists, otherwise an empty - * Optional + * @return an {@link Optional} containing the stored version of the user if + * found; otherwise, an empty {@link Optional} */ Optional find(GeorchestraUser mappedUser); /** - * Finds the stored user that belongs to the {@code mappedUser} or creates it if - * it doesn't exist in the users repository. + * Retrieves the stored user corresponding to the given {@code mappedUser}, or + * creates a new user if one does not already exist in the repository. *

- * When a user is created, an {@link AccountCreated} event must be published to - * the {@link ApplicationEventPublisher}. - * - * @param mappedUser the user {@link ResolveGeorchestraUserGlobalFilter} - * resolved by calling + * If a new user is created, an {@link AccountCreated} event must be published + * via the {@link ApplicationEventPublisher} to notify the system of the new + * account. + *

+ * + * @param mappedUser the user resolved by + * {@link ResolveGeorchestraUserGlobalFilter}, obtained + * through a call to * {@link GeorchestraUserMapper#resolve(Authentication)} - * @return the stored version of the user, whether it existed or was created as - * the result of calling this method. + * @return the stored version of the user, either previously existing or newly + * created */ GeorchestraUser getOrCreate(GeorchestraUser mappedUser); - -} +} \ No newline at end of file diff --git a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/ldap/GeorchestraLdapAccountManagementConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/ldap/GeorchestraLdapAccountManagementConfiguration.java index d03501ce..35e5a092 100644 --- a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/ldap/GeorchestraLdapAccountManagementConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/ldap/GeorchestraLdapAccountManagementConfiguration.java @@ -41,10 +41,34 @@ import org.springframework.ldap.pool.factory.PoolingContextSource; import org.springframework.ldap.pool.validation.DefaultDirContextValidator; +/** + * Spring Boot configuration class for geOrchestra's LDAP-based account + * management. + *

+ * This class defines beans for managing LDAP user accounts, roles, and + * organizations using Spring LDAP and pooled connections. + *

+ *

+ * The configuration is driven by properties defined in + * {@link GeorchestraGatewaySecurityConfigProperties}. + *

+ */ @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties(GeorchestraGatewaySecurityConfigProperties.class) public class GeorchestraLdapAccountManagementConfiguration { + /** + * Defines the primary {@link AccountManager} bean using LDAP as the backend. + * + * @param eventPublisher the event publisher for account-related events + * @param accountDao the DAO for managing user accounts in LDAP + * @param roleDao the DAO for managing roles in LDAP + * @param orgsDao the DAO for managing organizations in LDAP + * @param demultiplexingUsersApi API for resolving users based on OAuth2 + * credentials + * @param configProperties the security configuration properties + * @return an instance of {@link LdapAccountsManager} + */ @Bean AccountManager ldapAccountsManager(// ApplicationEventPublisher eventPublisher, // @@ -53,16 +77,29 @@ AccountManager ldapAccountsManager(// OrgsDao orgsDao, // DemultiplexingUsersApi demultiplexingUsersApi, GeorchestraGatewaySecurityConfigProperties configProperties) { - return new LdapAccountsManager(eventPublisher::publishEvent, accountDao, roleDao, orgsDao, demultiplexingUsersApi, configProperties); } + /** + * Registers a {@link CreateAccountUserCustomizer} bean to handle automatic + * account creation when a user logs in via trusted authentication mechanisms. + * + * @param accountManager the account manager responsible for user retrieval and + * creation + * @return a {@link CreateAccountUserCustomizer} instance + */ @Bean CreateAccountUserCustomizer createAccountUserCustomizer(AccountManager accountManager) { return new CreateAccountUserCustomizer(accountManager); } + /** + * Creates an LDAP context source for connecting to a single LDAP directory. + * + * @param config the LDAP configuration properties + * @return a configured {@link LdapContextSource} instance + */ @Bean LdapContextSource singleContextSource(GeorchestraGatewaySecurityConfigProperties config) { ExtendedLdapConfig ldapConfig = config.extendedEnabled().get(0); @@ -74,6 +111,12 @@ LdapContextSource singleContextSource(GeorchestraGatewaySecurityConfigProperties return singleContextSource; } + /** + * Configures a pooling LDAP context source to optimize connection management. + * + * @param singleContextSource the base LDAP context source + * @return a {@link PoolingContextSource} with connection pooling enabled + */ @Bean PoolingContextSource contextSource(LdapContextSource singleContextSource) { PoolingContextSource contextSource = new PoolingContextSource(); @@ -88,11 +131,24 @@ PoolingContextSource contextSource(LdapContextSource singleContextSource) { return contextSource; } + /** + * Creates an {@link LdapTemplate} for interacting with LDAP. + * + * @param contextSource the pooled LDAP context source + * @return an initialized {@link LdapTemplate} + */ @Bean - LdapTemplate ldapTemplate(PoolingContextSource contextSource) throws Exception { + LdapTemplate ldapTemplate(PoolingContextSource contextSource) { return new LdapTemplate(contextSource); } + /** + * Creates a {@link RoleDao} implementation for managing LDAP roles. + * + * @param ldapTemplate the LDAP template for querying LDAP + * @param config the security configuration properties + * @return a configured {@link RoleDaoImpl} + */ @Bean RoleDao roleDao(LdapTemplate ldapTemplate, GeorchestraGatewaySecurityConfigProperties config) { RoleDaoImpl impl = new RoleDaoImpl(); @@ -101,6 +157,13 @@ RoleDao roleDao(LdapTemplate ldapTemplate, GeorchestraGatewaySecurityConfigPrope return impl; } + /** + * Creates an {@link OrgsDao} implementation for managing LDAP organizations. + * + * @param ldapTemplate the LDAP template for querying LDAP + * @param config the security configuration properties + * @return a configured {@link OrgsDaoImpl} + */ @Bean OrgsDao orgsDao(LdapTemplate ldapTemplate, GeorchestraGatewaySecurityConfigProperties config) { OrgsDaoImpl impl = new OrgsDaoImpl(); @@ -112,35 +175,33 @@ OrgsDao orgsDao(LdapTemplate ldapTemplate, GeorchestraGatewaySecurityConfigPrope return impl; } + /** + * Creates an {@link AccountDao} implementation for managing user accounts in + * LDAP. + * + * @param ldapTemplate the LDAP template for querying LDAP + * @param config the security configuration properties + * @return a configured {@link AccountDaoImpl} + */ @Bean AccountDao accountDao(LdapTemplate ldapTemplate, GeorchestraGatewaySecurityConfigProperties config) { ExtendedLdapConfig ldapConfig = config.extendedEnabled().get(0); - String baseDn = ldapConfig.getBaseDn(); - String userSearchBaseDN = ldapConfig.getUsersRdn(); - String roleSearchBaseDN = ldapConfig.getRolesRdn(); - - // we don't need a configuration property for this, - // we don't allow pending users to log in. The LdapAuthenticationProvider won't - // even look them up. - final String pendingUsersSearchBaseDN = "ou=pendingusers"; - AccountDaoImpl impl = new AccountDaoImpl(ldapTemplate); - impl.setBasePath(baseDn); - impl.setUserSearchBaseDN(userSearchBaseDN); - impl.setRoleSearchBaseDN(roleSearchBaseDN); - impl.setPendingUserSearchBaseDN(pendingUsersSearchBaseDN); - - String orgSearchBaseDN = ldapConfig.getOrgsRdn(); - requireNonNull(orgSearchBaseDN); - impl.setOrgSearchBaseDN(orgSearchBaseDN); - - final String pendingOrgSearchBaseDN = "ou=pendingorgs"; - impl.setPendingOrgSearchBaseDN(pendingOrgSearchBaseDN); - + impl.setBasePath(ldapConfig.getBaseDn()); + impl.setUserSearchBaseDN(ldapConfig.getUsersRdn()); + impl.setRoleSearchBaseDN(ldapConfig.getRolesRdn()); + impl.setPendingUserSearchBaseDN("ou=pendingusers"); + impl.setOrgSearchBaseDN(requireNonNull(ldapConfig.getOrgsRdn())); + impl.setPendingOrgSearchBaseDN("ou=pendingorgs"); impl.init(); return impl; } + /** + * Defines role protection rules for preventing modification of critical roles. + * + * @return a {@link RoleProtected} instance with predefined protected roles + */ @Bean RoleProtected roleProtected() { RoleProtected roleProtected = new RoleProtected(); diff --git a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/ldap/LdapAccountsManager.java b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/ldap/LdapAccountsManager.java index 72978696..e4961492 100644 --- a/gateway/src/main/java/org/georchestra/gateway/accounts/admin/ldap/LdapAccountsManager.java +++ b/gateway/src/main/java/org/georchestra/gateway/accounts/admin/ldap/LdapAccountsManager.java @@ -49,19 +49,64 @@ import lombok.extern.slf4j.Slf4j; /** - * {@link AccountManager} that fetches and creates {@link GeorchestraUser}s from - * the Georchestra extended LDAP service provided by an {@link AccountDao} and - * {@link RoleDao}. + * Implementation of {@link AccountManager} that manages {@link GeorchestraUser} + * accounts through an extended LDAP service. + *

+ * This class provides methods for fetching, creating, and managing user + * accounts stored in LDAP via an {@link AccountDao} and {@link RoleDao}. If a + * user does not exist, it ensures the necessary roles and organizational + * memberships are created. + *

+ * + *

+ * Role names are automatically prefixed with {@code "ROLE_"} if missing. + *

+ * + * @see AccountManager + * @see AbstractAccountsManager + * @see DemultiplexingUsersApi + * @see AccountDao + * @see RoleDao + * @see OrgsDao */ @Slf4j(topic = "org.georchestra.gateway.accounts.admin.ldap") class LdapAccountsManager extends AbstractAccountsManager { + /** Configuration properties for security-related settings. */ private final @NonNull GeorchestraGatewaySecurityConfigProperties georchestraGatewaySecurityConfigProperties; + + /** DAO for managing user accounts in LDAP. */ private final @NonNull AccountDao accountDao; + + /** DAO for managing roles and permissions in LDAP. */ private final @NonNull RoleDao roleDao; + + /** DAO for managing organizations in LDAP. */ private final @NonNull OrgsDao orgsDao; + + /** API for resolving users based on OAuth2 credentials. */ private final @NonNull DemultiplexingUsersApi demultiplexingUsersApi; + /** + * Constructs an instance of {@code LdapAccountsManager}. + * + * @param eventPublisher the application event + * publisher used for + * publishing account-related + * events + * @param accountDao the DAO responsible for + * managing user accounts in + * LDAP + * @param roleDao the DAO responsible for + * managing roles in LDAP + * @param orgsDao the DAO responsible for + * managing organizations in + * LDAP + * @param demultiplexingUsersApi the API used for resolving + * users by OAuth2 credentials + * @param georchestraGatewaySecurityConfigProperties configuration properties + * for security settings + */ public LdapAccountsManager(ApplicationEventPublisher eventPublisher, AccountDao accountDao, RoleDao roleDao, OrgsDao orgsDao, DemultiplexingUsersApi demultiplexingUsersApi, GeorchestraGatewaySecurityConfigProperties georchestraGatewaySecurityConfigProperties) { @@ -73,23 +118,77 @@ public LdapAccountsManager(ApplicationEventPublisher eventPublisher, AccountDao this.georchestraGatewaySecurityConfigProperties = georchestraGatewaySecurityConfigProperties; } + /** + * Retrieves a stored user based on their OAuth2 provider and unique identifier. + *

+ * This method queries the {@link DemultiplexingUsersApi} for a user with the + * given OAuth2 provider and unique identifier. If found, the user's roles are + * normalized to ensure they are properly prefixed. + *

+ * + * @param oAuth2Provider the OAuth2 provider (e.g., Google, GitHub) + * @param oAuth2Uid the unique identifier assigned by the OAuth2 provider + * @return an {@link Optional} containing the user if found, otherwise empty + */ @Override protected Optional findByOAuth2Uid(@NonNull String oAuth2Provider, @NonNull String oAuth2Uid) { return demultiplexingUsersApi.findByOAuth2Uid(oAuth2Provider, oAuth2Uid).map(this::ensureRolesPrefixed); } + /** + * Retrieves a stored user based on their username. + *

+ * This method queries the {@link DemultiplexingUsersApi} for a user with the + * given username. If found, the user's roles are normalized to ensure they are + * properly prefixed. + *

+ * + * @param username the username to search for + * @return an {@link Optional} containing the user if found, otherwise empty + */ @Override protected Optional findByUsername(@NonNull String username) { return demultiplexingUsersApi.findByUsername(username).map(this::ensureRolesPrefixed); } + /** + * Ensures all roles assigned to a user are prefixed with {@code "ROLE_"}. + *

+ * If a role does not start with "ROLE_", this method adds the prefix. This + * normalization ensures consistency when handling role-based access. + *

+ * + * @param user the user whose roles need to be normalized + * @return the updated {@link GeorchestraUser} with properly formatted roles + */ private GeorchestraUser ensureRolesPrefixed(GeorchestraUser user) { List roles = user.getRoles().stream().filter(Objects::nonNull) - .map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r).toList(); - user.setRoles(new ArrayList<>(roles)); + .map(r -> r.startsWith("ROLE_") ? r : "ROLE_" + r).toList(); // Converts to an immutable list + user.setRoles(new ArrayList<>(roles)); // Ensures mutability return user; } + /** + * Creates a new user in the LDAP repository if one does not already exist. + *

+ * This method first attempts to insert the user into LDAP. If an error occurs + * due to duplicate emails or usernames, appropriate exceptions are thrown. + *

+ *

+ * If the user is successfully inserted, their organization is ensured to exist. + * If an error occurs while managing the organization, the user account creation + * is rolled back. + *

+ *

+ * Finally, roles are assigned to the user to ensure correct access levels. + *

+ * + * @param mapped the user to create + * @throws DuplicatedEmailFoundException if a user with the same email + * already exists + * @throws DuplicatedUsernameFoundException if a user with the same username + * already exists + */ @Override protected void createInternal(GeorchestraUser mapped) throws DuplicatedEmailFoundException { Account newAccount = mapToAccountBrief(mapped); @@ -115,6 +214,12 @@ protected void createInternal(GeorchestraUser mapped) throws DuplicatedEmailFoun ensureRolesExist(mapped, newAccount); } + /** + * Ensures all roles assigned to a user are prefixed with {@code "ROLE_"}. + * + * @param user the user whose roles need to be normalized + * @return the updated user with properly formatted roles + */ private void ensureRolesExist(GeorchestraUser mapped, Account newAccount) { try {// account created, add roles if (!mapped.getRoles().contains("ROLE_USER")) { @@ -135,6 +240,11 @@ private void ensureRolesExist(GeorchestraUser mapped, Account newAccount) { } } + /** + * Ensures the organization associated with a user exists in LDAP. + * + * @param newAccount the account whose organization needs verification + */ private void ensureRoleExists(String role) throws DataServiceException { try { roleDao.findByCommonName(role); @@ -147,6 +257,20 @@ private void ensureRoleExists(String role) throws DataServiceException { } } + /** + * Maps a {@link GeorchestraUser} to a brief {@link Account} representation for + * LDAP storage. + *

+ * This method extracts key user details such as username, email, and + * organization, and constructs an {@link Account} object suitable for insertion + * into LDAP. The generated account is marked as non-pending and assigned a + * default organization if none is provided. + *

+ * + * @param preAuth the pre-authenticated {@link GeorchestraUser} containing user + * details + * @return a newly created {@link Account} object with mapped attributes + */ private Account mapToAccountBrief(@NonNull GeorchestraUser preAuth) { String username = preAuth.getUsername(); String email = preAuth.getEmail(); @@ -183,6 +307,12 @@ private void ensureOrgExists(@NonNull Account newAccount) { } } + /** + * Creates an organization and assigns the user to it. + * + * @param newAccount the user account to add to the new organization + * @param orgId the identifier of the organization + */ private void createOrgAndAddAccount(Account newAccount, final String orgId) { try { log.info("Org {} does not exist, trying to create it", orgId); @@ -200,6 +330,13 @@ private void addAccountToOrg(Account newAccount, Org org) { orgsDao.update(org); } + /** + * Finds an organization by its identifier. + * + * @param orgId the identifier of the organization + * @return an {@link Optional} containing the organization if found, otherwise + * empty + */ private Optional findOrg(String orgId) { try { return Optional.of(orgsDao.findByCommonName(orgId)); @@ -208,6 +345,11 @@ private Optional findOrg(String orgId) { } } + /** + * Rolls back user creation if an error occurs. + * + * @param newAccount the user account to remove from LDAP + */ private void rollbackAccount(Account newAccount) { try {// roll-back account accountDao.delete(newAccount); @@ -216,6 +358,9 @@ private void rollbackAccount(Account newAccount) { } } + /** + * Factory method to create a new org with the given id and return it + */ private Org newOrg(final String orgId) { Org org = new Org(); org.setId(orgId); diff --git a/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqAccountCreatedEventSender.java b/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqAccountCreatedEventSender.java index 06ddccee..44bd6f2f 100644 --- a/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqAccountCreatedEventSender.java +++ b/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqAccountCreatedEventSender.java @@ -27,26 +27,48 @@ import org.springframework.context.event.EventListener; /** - * Service bean that listens to {@link AccountCreated} events and publish a - * distributed event through rabbitmq to the {@literal OAUTH2-ACCOUNT-CREATION} + * A service bean that listens for {@link AccountCreated} events and publishes a + * distributed event through RabbitMQ to the {@literal OAUTH2-ACCOUNT-CREATION} * queue. + *

+ * This class is responsible for notifying other services when a new user + * account is created via OAuth2 authentication. It transforms the event data + * into a JSON message and sends it to the configured RabbitMQ routing key. + *

+ * + * @see AccountCreated + * @see AmqpTemplate */ - public class RabbitmqAccountCreatedEventSender { + /** The RabbitMQ queue name for OAuth2 account creation events. */ public static final String OAUTH2_ACCOUNT_CREATION = "OAUTH2-ACCOUNT-CREATION"; - private AmqpTemplate eventTemplate; + /** The AMQP template for sending messages to the RabbitMQ exchange. */ + private final AmqpTemplate eventTemplate; + /** + * Constructs a new {@code RabbitmqAccountCreatedEventSender}. + * + * @param eventTemplate the AMQP template used for sending messages + */ public RabbitmqAccountCreatedEventSender(AmqpTemplate eventTemplate) { this.eventTemplate = eventTemplate; } + /** + * Handles {@link AccountCreated} events and sends a message to the RabbitMQ + * queue if the new account was created via an OAuth2 provider. + * + * @param event the {@link AccountCreated} event containing user details + */ @EventListener public void on(AccountCreated event) { GeorchestraUser user = event.getUser(); final String oAuth2Provider = user.getOAuth2Provider(); - if (null != oAuth2Provider) { + + // Only send events for OAuth2-authenticated users + if (oAuth2Provider != null) { String fullName = user.getFirstName() + " " + user.getLastName(); String localUid = user.getUsername(); String email = user.getEmail(); @@ -56,6 +78,38 @@ public void on(AccountCreated event) { } } + /** + * Sends a message to RabbitMQ indicating that a new OAuth2 user account has + * been created. + *

+ * This method constructs a JSON object containing user details and publishes it + * to the RabbitMQ exchange with the routing key {@code routing-gateway}. + *

+ * + *

+ * Example JSON output: + *

+ * + *
+     * {
+     *   "uid": "550e8400-e29b-41d4-a716-446655440000",
+     *   "subject": "OAUTH2-ACCOUNT-CREATION",
+     *   "fullName": "John Doe",
+     *   "localUid": "jdoe",
+     *   "email": "johndoe@example.com",
+     *   "organization": "Example Corp",
+     *   "providerName": "Google",
+     *   "providerUid": "1234567890"
+     * }
+     * 
+ * + * @param fullName the full name of the user + * @param localUid the local username assigned to the user + * @param email the email address of the user + * @param organization the organization to which the user belongs + * @param providerName the name of the OAuth2 provider (e.g., Google, GitHub) + * @param providerUid the unique identifier assigned by the OAuth2 provider + */ public void sendNewOAuthAccountMessage(String fullName, String localUid, String email, String organization, String providerName, String providerUid) { JSONObject jsonObj = new JSONObject(); @@ -67,6 +121,8 @@ public void sendNewOAuthAccountMessage(String fullName, String localUid, String jsonObj.put("organization", organization); jsonObj.put("providerName", providerName); jsonObj.put("providerUid", providerUid); - eventTemplate.convertAndSend("routing-gateway", jsonObj.toString());// send + + // Publish the message to the RabbitMQ queue + eventTemplate.convertAndSend("routing-gateway", jsonObj.toString()); } -} \ No newline at end of file +} diff --git a/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqEventsConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqEventsConfiguration.java index a1fa9322..9208646b 100644 --- a/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqEventsConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqEventsConfiguration.java @@ -29,29 +29,58 @@ import org.springframework.context.annotation.ImportResource; /** - * {@link Configuration @Configuration} to enable sending events over rabbitmq * + * Configures RabbitMQ event handling for geOrchestra account creation events. *

- * When an account is created in geOrchestra's LDAP in response to a - * pre-authenticated or OIDC successful authentication, an - * {@link AccountCreated} event will be catch up and sent over the wire. - * + * This configuration enables the system to send events via RabbitMQ when an + * account is created in geOrchestra's LDAP in response to pre-authenticated or + * OpenID Connect (OIDC) authentication. + *

+ *

+ * When an {@link AccountCreated} event is published, it is intercepted and + * forwarded to the RabbitMQ event queue. + *

+ * + *

+ * This configuration also imports RabbitMQ-related XML context files: + *

    + *
  • {@code rabbit-listener-context.xml} - Configures message listeners
  • + *
  • {@code rabbit-sender-context.xml} - Configures message senders
  • + *
+ *

+ * + * @see AccountCreated * @see RabbitmqEventsConfigurationProperties - * */ @Configuration @EnableConfigurationProperties(RabbitmqEventsConfigurationProperties.class) @ImportResource({ "classpath:rabbit-listener-context.xml", "classpath:rabbit-sender-context.xml" }) public class RabbitmqEventsConfiguration { + /** + * Defines the RabbitMQ event sender for publishing account creation events. + * + * @param eventTemplate the RabbitMQ {@link RabbitTemplate} used for message + * publishing + * @return an instance of {@link RabbitmqAccountCreatedEventSender} + */ @Bean RabbitmqAccountCreatedEventSender eventsSender(@Qualifier("eventTemplate") RabbitTemplate eventTemplate) { return new RabbitmqAccountCreatedEventSender(eventTemplate); } + /** + * Configures a RabbitMQ connection factory. + *

+ * This method initializes a {@link CachingConnectionFactory} using the RabbitMQ + * connection properties defined in + * {@link RabbitmqEventsConfigurationProperties}. + *

+ * + * @param config the RabbitMQ configuration properties + * @return a configured {@link CachingConnectionFactory} instance + */ @Bean - org.springframework.amqp.rabbit.connection.CachingConnectionFactory connectionFactory( - RabbitmqEventsConfigurationProperties config) { - + CachingConnectionFactory connectionFactory(RabbitmqEventsConfigurationProperties config) { com.rabbitmq.client.ConnectionFactory fac = new com.rabbitmq.client.ConnectionFactory(); fac.setHost(config.getHost()); fac.setPort(config.getPort()); @@ -61,9 +90,18 @@ org.springframework.amqp.rabbit.connection.CachingConnectionFactory connectionFa return new CachingConnectionFactory(fac); } + /** + * Configures a health indicator for monitoring the RabbitMQ connection status. + *

+ * This health indicator integrates with Spring Boot Actuator, allowing + * real-time monitoring of the RabbitMQ connection through health endpoints. + *

+ * + * @param eventTemplate the RabbitMQ template used for event communication + * @return a configured {@link RabbitHealthIndicator} + */ @Bean RabbitHealthIndicator rabbitHealthIndicator(@Qualifier("eventTemplate") RabbitTemplate eventTemplate) { return new RabbitHealthIndicator(eventTemplate); } - } diff --git a/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqEventsConfigurationProperties.java b/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqEventsConfigurationProperties.java index 63c5f776..cf30bef6 100644 --- a/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqEventsConfigurationProperties.java +++ b/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqEventsConfigurationProperties.java @@ -25,8 +25,37 @@ import lombok.Generated; /** - * Configuration properties to enable rabbit-mq event dispatching of accounts - * created + * Configuration properties for RabbitMQ event dispatching related to account + * creation. + *

+ * These properties define how geOrchestra should publish events to RabbitMQ + * when a new LDAP account is created following a user's first successful login + * via OAuth2 authentication. + *

+ *

+ * The properties are prefixed with + * {@code georchestra.gateway.security.events.rabbitmq} and can be configured in + * the application's configuration file (e.g., {@code application.yml}). + *

+ * + *

+ * Example Configuration: + *

+ * + *
+ * georchestra:
+ *   gateway:
+ *     security:
+ *       events:
+ *         rabbitmq:
+ *           enabled: true
+ *           host: rabbitmq.example.com
+ *           port: 5672
+ *           user: myRabbitUser
+ *           password: mySecretPassword
+ * 
+ * + * @see org.springframework.boot.context.properties.ConfigurationProperties */ @Data @Generated @@ -34,28 +63,41 @@ @ConfigurationProperties(prefix = RabbitmqEventsConfigurationProperties.PREFIX) public class RabbitmqEventsConfigurationProperties { + /** The prefix for all RabbitMQ-related configuration properties. */ public static final String PREFIX = "georchestra.gateway.security.events.rabbitmq"; + + /** The configuration key to enable or disable RabbitMQ event dispatching. */ public static final String ENABLED = PREFIX + ".enabled"; /** - * Whether rabbit-mq events should be sent when an LDAP account was created upon - * a first successful login through OAuth2 + * Whether RabbitMQ events should be sent when an LDAP account is created + * following a user's first successful login via OAuth2 authentication. + *

+ * Default: {@code false}. + *

*/ private boolean enabled; + /** - * The rabbit-mq host name + * The hostname of the RabbitMQ server. */ private String host; + /** - * The rabbit-mq host port number + * The port number of the RabbitMQ server. + *

+ * Default: {@code 5672} (the default AMQP port). + *

*/ private int port; + /** - * The rabbit-mq authentication user + * The username used for authentication with the RabbitMQ server. */ private String user; + /** - * The rabbit-mq authentication password + * The password used for authentication with the RabbitMQ server. */ private String password; } diff --git a/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqEventsListener.java b/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqEventsListener.java index 251664b7..30ff3cb7 100644 --- a/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqEventsListener.java +++ b/gateway/src/main/java/org/georchestra/gateway/accounts/events/rabbitmq/RabbitmqEventsListener.java @@ -30,33 +30,69 @@ import lombok.extern.slf4j.Slf4j; +/** + * Listens for messages from RabbitMQ and processes received events. + *

+ * This listener processes incoming messages related to OAuth2 account creation + * events. It ensures that duplicate messages are not logged more than once by + * maintaining a synchronized set of processed message UIDs. + *

+ * + *

+ * If an error occurs while processing a message, it is logged and silently + * discarded. + *

+ */ @Slf4j public class RabbitmqEventsListener implements MessageListener { + /** + * The subject indicating that an OAuth2 account creation event has been + * received. + */ public static final String OAUTH2_ACCOUNT_CREATION_RECEIVED = "OAUTH2-ACCOUNT-CREATION-RECEIVED"; - private static Set synReceivedMessageUid = Collections.synchronizedSet(new HashSet()); + /** + * A synchronized set to track processed message UIDs and prevent duplicate + * processing. + */ + private static final Set synReceivedMessageUid = Collections.synchronizedSet(new HashSet<>()); + /** + * Processes an incoming RabbitMQ message. + *

+ * If the message contains a subject matching + * {@code OAUTH2-ACCOUNT-CREATION-RECEIVED} and has not already been processed, + * it logs the message content. + *

+ * + * @param message the incoming RabbitMQ message + */ + @Override public void onMessage(Message message) { try { String messageBody = new String(message.getBody()); JSONObject jsonObj = new JSONObject(messageBody); String uid = jsonObj.getString("uid"); String subject = jsonObj.getString("subject"); - if (subject.equals(OAUTH2_ACCOUNT_CREATION_RECEIVED) - && !synReceivedMessageUid.stream().anyMatch(s -> s.equals(uid))) { + + if (subject.equals(OAUTH2_ACCOUNT_CREATION_RECEIVED) && !synReceivedMessageUid.contains(uid)) { String msg = jsonObj.getString("msg"); synReceivedMessageUid.add(uid); log.info(msg); } - } catch (Exception e) { - log.error("Exception caught when evaluating a message from RabbitMq, it will be silently discarded.", e); + log.error("Exception caught when evaluating a message from RabbitMQ. It will be silently discarded.", e); } } + /** + * Returns the set of received message UIDs for testing purposes. + * + * @return an unmodifiable view of the received message UIDs + */ @VisibleForTesting public static Set getSynReceivedMessageUid() { - return synReceivedMessageUid; + return Collections.unmodifiableSet(synReceivedMessageUid); } -} \ No newline at end of file +} diff --git a/gateway/src/main/java/org/georchestra/gateway/app/GeorchestraGatewayApplication.java b/gateway/src/main/java/org/georchestra/gateway/app/GeorchestraGatewayApplication.java index cac78fe4..2a4171d1 100644 --- a/gateway/src/main/java/org/georchestra/gateway/app/GeorchestraGatewayApplication.java +++ b/gateway/src/main/java/org/georchestra/gateway/app/GeorchestraGatewayApplication.java @@ -38,21 +38,61 @@ import lombok.extern.slf4j.Slf4j; +/** + * Main application class for the geOrchestra Gateway. + *

+ * This class initializes the Spring Boot application, manages application-wide + * configuration, and sets up essential beans and event listeners. + *

+ * + *

+ * Most additional functionalities, such as security, routing, and external + * integrations, are contributed via Spring Boot + * {@link org.springframework.boot.autoconfigure.AutoConfiguration} classes. + * These auto-configurations enable features dynamically based on the + * application’s dependencies and configuration properties. + *

+ * + *

+ * The only explicitly defined controllers in this package are those required + * for gateway-specific endpoints, such as authentication entry points or + * request introspection. All other functionalities are provided through + * auto-configuration. + *

+ */ @Slf4j @SpringBootApplication @EnableConfigurationProperties(GeorchestraGatewaySecurityConfigProperties.class) public class GeorchestraGatewayApplication { + /** + * The route locator for retrieving gateway routes. Only used for reporting the + * number of configured routes at startup + */ private @Autowired RouteLocator routeLocator; + + /** The basename for message resources, configurable via properties. */ private @Value("${spring.messages.basename:}") String messagesBasename; + /** + * Entry point for the geOrchestra Gateway application. + * + * @param args command-line arguments + */ public static void main(String[] args) { SpringApplication.run(GeorchestraGatewayApplication.class, args); } /** - * REVISIT: why do we need to define this bean in the Application class and not - * in a configuration that depends on whether rabbit is enabled? + * Configures a {@link MessageSource} bean for loading internationalized + * messages. + *

+ * This method sets up a {@link ReloadableResourceBundleMessageSource} that + * loads messages from {@code classpath:messages/login} and additional basenames + * configured via {@code spring.messages.basename}. + *

+ * + * @return a configured {@link MessageSource} instance */ @Bean MessageSource messageSource() { @@ -64,11 +104,22 @@ MessageSource messageSource() { return messageSource; } + /** + * Handles the {@link ApplicationReadyEvent}, logging essential application + * details. + *

+ * This method retrieves environment properties, including the data directory, + * instance ID, available CPU cores, and memory usage, and logs them for + * debugging. It also counts the number of registered routes. + *

+ * + * @param event the application ready event + */ @EventListener(ApplicationReadyEvent.class) - public void onApplicationReady(ApplicationReadyEvent e) { - Environment env = e.getApplicationContext().getEnvironment(); + public void onApplicationReady(ApplicationReadyEvent event) { + Environment env = event.getApplicationContext().getEnvironment(); String datadir = env.getProperty("georchestra.datadir"); - if (null != datadir) { + if (datadir != null) { datadir = new File(datadir).getAbsolutePath(); } String app = env.getProperty("spring.application.name"); @@ -81,8 +132,12 @@ public void onApplicationReady(ApplicationReadyEvent e) { routeCount, instanceId, cpus, maxMem); } + /** + * Retrieves the maximum memory allocated to the JVM and formats it in MB or GB. + * + * @return the formatted maximum memory value + */ private String getMaxMem() { - String maxMem; DataSize maxMemBytes = DataSize.ofBytes(Runtime.getRuntime().maxMemory()); double value = maxMemBytes.toKilobytes() / 1024d; String unit = "MB"; @@ -90,7 +145,6 @@ private String getMaxMem() { value = value / 1024d; unit = "GB"; } - maxMem = String.format("%.2f %s", value, unit); - return maxMem; + return String.format("%.2f %s", value, unit); } } diff --git a/gateway/src/main/java/org/georchestra/gateway/app/LoginLogoutController.java b/gateway/src/main/java/org/georchestra/gateway/app/LoginLogoutController.java index 89d2042c..4aa1e6e0 100644 --- a/gateway/src/main/java/org/georchestra/gateway/app/LoginLogoutController.java +++ b/gateway/src/main/java/org/georchestra/gateway/app/LoginLogoutController.java @@ -10,14 +10,21 @@ * * geOrchestra is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with - * geOrchestra. If not, see . + * geOrchestra. If not, see . */ package org.georchestra.gateway.app; +import java.nio.file.Paths; +import java.util.HashMap; +import java.util.Map; +import java.util.Optional; + +import javax.annotation.PostConstruct; + import org.apache.commons.lang3.tuple.Pair; import org.georchestra.gateway.security.GeorchestraGatewaySecurityConfigProperties; import org.georchestra.gateway.security.GeorchestraGatewaySecurityConfigProperties.Server; @@ -30,86 +37,155 @@ import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; -import javax.annotation.PostConstruct; -import java.nio.file.Paths; -import java.util.HashMap; -import java.util.Map; -import java.util.Optional; - +/** + * Controller handling login and logout views for the geOrchestra gateway. + *

+ * This controller serves the login and logout pages, manages authentication + * options, and provides necessary attributes for rendering login-related + * templates. + *

+ * + *

+ * It supports authentication through: + *

    + *
  • LDAP (if enabled via configuration)
  • + *
  • OAuth2 providers registered in Spring Security
  • + *
  • Header-based authentication
  • + *
+ *

+ */ // We have to use the @Controller annotation, not the @RestController one // here, so that the login/logout page will go through the thymeleaf templating // system. @Controller public class LoginLogoutController { + /** Configuration properties for gateway security, including LDAP settings. */ private @Autowired(required = false) GeorchestraGatewaySecurityConfigProperties georchestraGatewaySecurityConfigProperties; + /** Whether LDAP authentication is enabled. */ private boolean ldapEnabled; + /** OAuth2 client configuration, if available. */ private @Autowired(required = false) OAuth2ClientProperties oauth2ClientConfig; + + /** Whether header-based authentication is enabled. */ private @Value("${georchestra.gateway.headerEnabled:true}") boolean headerEnabled; - // defined in georchestra datadir's default.properties + /** Path to the geOrchestra custom stylesheet, if configured. */ private @Value("${georchestraStylesheet:}") String georchestraStylesheet; + + /** Whether to use the legacy geOrchestra header. */ private @Value("${useLegacyHeader:false}") boolean useLegacyHeader; + + /** URL of the geOrchestra header component. */ private @Value("${headerUrl:/header/}") String headerUrl; + + /** Path to the geOrchestra header configuration file. */ private @Value("${headerConfigFile:}") String headerConfigFile; + + /** Height of the geOrchestra header in pixels. */ private @Value("${headerHeight:80}") int headerHeight; + + /** URL of the logo displayed in the header. */ private @Value("${logoUrl:}") String logoUrl; + + /** JavaScript file used to load the geOrchestra header. */ private @Value("${headerScript:https://cdn.jsdelivr.net/gh/georchestra/header@dist/header.js}") String headerScript; + /** + * Initializes authentication settings based on configuration properties. + *

+ * Determines whether LDAP authentication is enabled by checking the configured + * LDAP servers. + *

+ */ @PostConstruct void initialize() { if (georchestraGatewaySecurityConfigProperties != null) { ldapEnabled = georchestraGatewaySecurityConfigProperties.getLdap().values().stream() - .anyMatch((Server::isEnabled)); + .anyMatch(Server::isEnabled); } } + /** + * Handles logout page rendering. + *

+ * This method sets necessary attributes for rendering the logout page. + *

+ * + * @param model the model for passing attributes to the view + * @return the name of the logout view template + */ @GetMapping(path = "/logout") - public String logout(Model mdl) { - setHeaderAttributes(mdl); + public String logout(Model model) { + setHeaderAttributes(model); return "logout"; } + /** + * Handles the login page rendering and authentication flow. + *

+ * If only one OAuth2 provider is available and LDAP authentication is disabled, + * the user is automatically redirected to the provider’s authentication + * endpoint. Otherwise, the login page is rendered with available authentication + * options. + *

+ * + * @param allRequestParams request parameters, including authentication errors + * @param model the model for passing attributes to the view + * @return the login view name or a redirect to an authentication provider + */ @GetMapping(path = "/login") - public String loginPage(@RequestParam Map allRequestParams, Model mdl) { + public String loginPage(@RequestParam Map allRequestParams, Model model) { Map> oauth2LoginLinks = new HashMap<>(); - if (oauth2ClientConfig != null) { - oauth2ClientConfig.getRegistration().forEach((k, v) -> { - String clientName = Optional.ofNullable(v.getClientName()).orElse(k); - String providerPath = Paths.get("login/img/", k + ".png").toString(); + // Populate OAuth2 login links + if (oauth2ClientConfig != null) { + oauth2ClientConfig.getRegistration().forEach((key, value) -> { + String clientName = Optional.ofNullable(value.getClientName()).orElse(key); + String providerPath = Paths.get("login/img/", key + ".png").toString(); String logo = new ClassPathResource("static/" + providerPath).exists() ? providerPath : "login/img/default.png"; - oauth2LoginLinks.put("/oauth2/authorization/" + k, Pair.of(clientName, logo)); + oauth2LoginLinks.put("/oauth2/authorization/" + key, Pair.of(clientName, logo)); }); } + // Auto-redirect if only one OAuth2 provider is available and LDAP is disabled if (oauth2LoginLinks.size() == 1 && !ldapEnabled) { return "redirect:" + oauth2LoginLinks.keySet().stream().findFirst().orElseThrow(); } - setHeaderAttributes(mdl); - mdl.addAttribute("ldapEnabled", ldapEnabled); - mdl.addAttribute("oauth2LoginLinks", oauth2LoginLinks); - boolean expired = "expired_password".equals(allRequestParams.get("error")); - mdl.addAttribute("passwordExpired", expired); - boolean invalidCredentials = "invalid_credentials".equals(allRequestParams.get("error")); - mdl.addAttribute("invalidCredentials", invalidCredentials); - boolean duplicateAccount = "duplicate_account".equals(allRequestParams.get("error")); - mdl.addAttribute("duplicateAccount", duplicateAccount); + // Set model attributes for login page rendering + setHeaderAttributes(model); + model.addAttribute("ldapEnabled", ldapEnabled); + model.addAttribute("oauth2LoginLinks", oauth2LoginLinks); + + // Handle authentication error messages + model.addAttribute("passwordExpired", "expired_password".equals(allRequestParams.get("error"))); + model.addAttribute("invalidCredentials", "invalid_credentials".equals(allRequestParams.get("error"))); + model.addAttribute("duplicateAccount", "duplicate_account".equals(allRequestParams.get("error"))); + return "login"; } - private void setHeaderAttributes(Model mdl) { - mdl.addAttribute("georchestraStylesheet", georchestraStylesheet); - mdl.addAttribute("useLegacyHeader", useLegacyHeader); - mdl.addAttribute("headerUrl", headerUrl); - mdl.addAttribute("headerHeight", headerHeight); - mdl.addAttribute("logoUrl", logoUrl); - mdl.addAttribute("headerConfigFile", headerConfigFile); - mdl.addAttribute("headerEnabled", headerEnabled); - mdl.addAttribute("headerScript", headerScript); + /** + * Sets header-related attributes in the model for rendering views. + *

+ * These attributes control the appearance and behavior of the geOrchestra + * header displayed on the login and logout pages. + *

+ * + * @param model the model where attributes will be added + */ + private void setHeaderAttributes(Model model) { + model.addAttribute("georchestraStylesheet", georchestraStylesheet); + model.addAttribute("useLegacyHeader", useLegacyHeader); + model.addAttribute("headerUrl", headerUrl); + model.addAttribute("headerHeight", headerHeight); + model.addAttribute("logoUrl", logoUrl); + model.addAttribute("headerConfigFile", headerConfigFile); + model.addAttribute("headerEnabled", headerEnabled); + model.addAttribute("headerScript", headerScript); } } diff --git a/gateway/src/main/java/org/georchestra/gateway/app/StyleConfigController.java b/gateway/src/main/java/org/georchestra/gateway/app/StyleConfigController.java index 08ce6d56..2ded201a 100644 --- a/gateway/src/main/java/org/georchestra/gateway/app/StyleConfigController.java +++ b/gateway/src/main/java/org/georchestra/gateway/app/StyleConfigController.java @@ -10,11 +10,11 @@ * * geOrchestra is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with - * geOrchestra. If not, see . + * geOrchestra. If not, see . */ package org.georchestra.gateway.app; @@ -28,17 +28,31 @@ import reactor.core.publisher.Mono; +/** + * Controller that provides the geOrchestra UI style configuration. + *

+ * This controller exposes an endpoint that returns style-related settings, such + * as the stylesheet URL and logo URL, used for customizing the user interface. + * The values are loaded from the geOrchestra data directory's + * {@code default.properties}. + *

+ */ @RestController public class StyleConfigController { - private String georchestraStylesheet; - private String logoUrl; + /** The URL of the custom stylesheet for geOrchestra, if defined. */ + private final String georchestraStylesheet; + + /** The URL of the logo used in geOrchestra, if defined. */ + private final String logoUrl; /** - * @param georchestraStylesheet defined in georchestra datadir's - * default.properties - * @param logoUrl defined in georchestra datadir's - * default.properties + * Constructs a {@code StyleConfigController} with style configuration values. + * + * @param georchestraStylesheet the URL of the geOrchestra stylesheet, usually + * loaded from {@code default.properties} + * @param logoUrl the URL of the geOrchestra logo, usually loaded + * from {@code default.properties} */ public StyleConfigController(@Value("${georchestraStylesheet:}") String georchestraStylesheet, @Value("${logoUrl:}") String logoUrl) { @@ -46,13 +60,32 @@ public StyleConfigController(@Value("${georchestraStylesheet:}") String georches this.logoUrl = logoUrl; } + /** + * Provides the geOrchestra UI style configuration as a JSON object. + *

+ * This endpoint returns a JSON response containing the configured stylesheet + * URL and logo URL. + *

+ * + *

+ * Example Response: + *

+ * + *
+     * {
+     *   "stylesheet": "https://example.com/custom.css",
+     *   "logo": "https://example.com/logo.png"
+     * }
+     * 
+ * + * @return a reactive {@link Mono} containing a map with style configuration + * properties + */ @GetMapping(path = "/style-config", produces = MediaType.APPLICATION_JSON_VALUE) public Mono> styleConfig() { - - Map ret = new LinkedHashMap<>(); - ret.put("stylesheet", georchestraStylesheet); - ret.put("logo", logoUrl); - return Mono.just(ret); - + Map config = new LinkedHashMap<>(); + config.put("stylesheet", georchestraStylesheet); + config.put("logo", logoUrl); + return Mono.just(config); } -} +} \ No newline at end of file diff --git a/gateway/src/main/java/org/georchestra/gateway/app/WhoamiController.java b/gateway/src/main/java/org/georchestra/gateway/app/WhoamiController.java index f0b53707..d7fe6926 100644 --- a/gateway/src/main/java/org/georchestra/gateway/app/WhoamiController.java +++ b/gateway/src/main/java/org/georchestra/gateway/app/WhoamiController.java @@ -10,11 +10,11 @@ * * geOrchestra is distributed in the hope that it will be useful, but WITHOUT * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or - * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for * more details. * * You should have received a copy of the GNU General Public License along with - * geOrchestra. If not, see . + * geOrchestra. If not, see . */ package org.georchestra.gateway.app; @@ -33,15 +33,65 @@ import reactor.core.publisher.Mono; +/** + * Controller that provides user authentication details. + *

+ * This controller exposes an endpoint to return information about the currently + * authenticated user, including their mapped {@link GeorchestraUser} details. + *

+ */ @RestController public class WhoamiController { - private GeorchestraUserMapper userMapper; + /** + * The user mapper responsible for resolving authentication details into a + * {@link GeorchestraUser}. + */ + private final GeorchestraUserMapper userMapper; + /** + * Constructs a {@code WhoamiController} with a user mapper for authentication + * resolution. + * + * @param userMapper the {@link GeorchestraUserMapper} used to resolve + * authentication details + */ public WhoamiController(GeorchestraUserMapper userMapper) { this.userMapper = userMapper; } + /** + * Returns details about the currently authenticated user. + *

+ * This endpoint returns a JSON response containing the mapped + * {@link GeorchestraUser} object and the raw {@link Authentication} object. If + * the user is not authenticated, both values will be {@code null}. + *

+ * + *

+ * Example Response: + *

+ * + *
+     * {
+     *   "GeorchestraUser": {
+     *     "username": "jdoe",
+     *     "email": "jdoe@example.com",
+     *     "roles": ["ROLE_USER"],
+     *     "organization": "ExampleOrg"
+     *   },
+     *   "org.springframework.security.authentication.UsernamePasswordAuthenticationToken": {
+     *     "principal": "jdoe",
+     *     "authorities": ["ROLE_USER"]
+     *   }
+     * }
+     * 
+ * + * @param principal the currently authenticated user, if available + * @param exchange the current web exchange request context + * @return a reactive {@link Mono} containing a map with user authentication + * details + */ @GetMapping(path = "/whoami", produces = MediaType.APPLICATION_JSON_VALUE) public Mono> whoami(Authentication principal, ServerWebExchange exchange) { GeorchestraUser user; @@ -50,7 +100,6 @@ public Mono> whoami(Authentication principal, ServerWebExcha } catch (DuplicatedEmailFoundException e) { user = null; } - Map ret = new LinkedHashMap<>(); ret.put("GeorchestraUser", user); if (principal == null) { diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/ConditionalOnCreateLdapAccounts.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/ConditionalOnCreateLdapAccounts.java index be2ba2c1..7573479f 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/ConditionalOnCreateLdapAccounts.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/ConditionalOnCreateLdapAccounts.java @@ -28,8 +28,26 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; /** + * Meta-annotation that enables a bean only if LDAP account creation is allowed. + *

+ * This annotation is used to conditionally enable beans when both of the + * following conditions are met: + *

    + *
  • The default geOrchestra LDAP integration is enabled (via + * {@link ConditionalOnDefaultGeorchestraLdapEnabled}).
  • + *
  • The property + * {@code georchestra.gateway.security.create-non-existing-users-in-l-d-a-p} is + * set to {@code true}.
  • + *
+ *

+ * + *

+ * If the property is missing or explicitly set to {@code false}, the annotated + * bean will not be created. + *

* * @see ConditionalOnDefaultGeorchestraLdapEnabled + * @see ConditionalOnProperty */ @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/ConditionalOnDefaultGeorchestraLdapEnabled.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/ConditionalOnDefaultGeorchestraLdapEnabled.java index 579a9bec..b65a6a41 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/ConditionalOnDefaultGeorchestraLdapEnabled.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/ConditionalOnDefaultGeorchestraLdapEnabled.java @@ -29,7 +29,26 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; /** + * Meta-annotation that enables a bean only if the default geOrchestra LDAP + * integration is enabled. + *

+ * This annotation is used to conditionally activate beans when both of the + * following conditions are met: + *

    + *
  • LDAP is enabled for geOrchestra (via + * {@link ConditionalOnLdapEnabled}).
  • + *
  • The property {@code georchestra.gateway.security.ldap.default.enabled} is + * set to {@code true}.
  • + *
+ *

* + *

+ * If the property is missing or explicitly set to {@code false}, the annotated + * bean will not be created. + *

+ * + * @see ConditionalOnLdapEnabled + * @see ConditionalOnProperty */ @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/GeorchestraLdapAccountsCreationAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/GeorchestraLdapAccountsCreationAutoConfiguration.java index fd8bf730..aadfe6cc 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/GeorchestraLdapAccountsCreationAutoConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/GeorchestraLdapAccountsCreationAutoConfiguration.java @@ -32,10 +32,35 @@ import lombok.RequiredArgsConstructor; /** - * {@link AutoConfiguration @AutoConfiguration} - * + * Auto-configuration for LDAP account creation in geOrchestra. + *

+ * This configuration enables automatic LDAP account creation when the required + * conditions are met: + *

    + *
  • {@code georchestra.gateway.security.create-non-existing-users-in-l-d-a-p} + * is set to {@code true}.
  • + *
  • An extended LDAP configuration is present.
  • + *
+ *

+ * + *

+ * If no extended LDAP configuration is found, the application will fail to + * start. + *

+ * + *

+ * This class imports additional configurations: + *

    + *
  • {@link GeorchestraLdapAccountManagementConfiguration} - Manages + * LDAP-based user accounts.
  • + *
  • {@link ExtendedLdapAuthenticationConfiguration} - Provides extended LDAP + * authentication support.
  • + *
+ *

+ * * @see ConditionalOnCreateLdapAccounts * @see GeorchestraLdapAccountManagementConfiguration + * @see ExtendedLdapAuthenticationConfiguration */ @AutoConfiguration @ConditionalOnCreateLdapAccounts @@ -43,13 +68,21 @@ @RequiredArgsConstructor public class GeorchestraLdapAccountsCreationAutoConfiguration { + /** The list of extended LDAP configurations required for account creation. */ @NonNull private final List configs; + /** + * Ensures that an extended LDAP configuration is available. + *

+ * If no extended LDAP configurations are present, this method throws an + * {@link IllegalStateException} to prevent startup with invalid settings. + *

+ */ @PostConstruct - void failIfNoExtendedLdapCongfigs() { + void failIfNoExtendedLdapConfigs() { if (configs.isEmpty()) { throw new IllegalStateException("LDAP account creation requires an extended LDAP configuration"); } } -} \ No newline at end of file +} diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/RabbitmqEventsAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/RabbitmqEventsAutoConfiguration.java index e5c13399..834bc7f5 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/RabbitmqEventsAutoConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/accounts/RabbitmqEventsAutoConfiguration.java @@ -26,17 +26,33 @@ import org.springframework.context.annotation.Import; /** - * {@link AutoConfiguration @AutoConfiguration} to enable sending events over - * rabbitmq when it is enabled through - * {@literal georchestra.gateway.security.events.rabbitmq = true}. + * Auto-configuration for enabling RabbitMQ event dispatching when configured. *

- * When an account is created in geOrchestra's LDAP in response to a - * pre-authenticated or OIDC successful authentication, an - * {@link AccountCreated} event will be catch up and sent over the wire. - * - * + * This configuration enables RabbitMQ integration when the following conditions + * are met: + *

    + *
  • LDAP account creation is enabled + * ({@link ConditionalOnCreateLdapAccounts}).
  • + *
  • The property {@code georchestra.gateway.security.events.rabbitmq} is set + * to {@code true}.
  • + *
+ *

+ * + *

+ * When a user account is created in geOrchestra's LDAP following a successful + * authentication via pre-authenticated headers or OIDC, an + * {@link AccountCreated} event is published. This event is then transmitted via + * RabbitMQ. + *

+ * + *

+ * This class imports {@link RabbitmqEventsConfiguration}, which defines the + * RabbitMQ message sender and event handling beans. + *

+ * * @see ConditionalOnCreateLdapAccounts * @see RabbitmqEventsConfiguration + * @see RabbitmqEventsConfigurationProperties */ @AutoConfiguration @ConditionalOnCreateLdapAccounts diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/CustomErrorAttributes.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/CustomErrorAttributes.java index b39e655c..8b8dc884 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/CustomErrorAttributes.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/CustomErrorAttributes.java @@ -25,34 +25,78 @@ import org.springframework.boot.autoconfigure.web.reactive.error.ErrorWebFluxAutoConfiguration; import org.springframework.boot.web.error.ErrorAttributeOptions; import org.springframework.boot.web.reactive.error.DefaultErrorAttributes; -import org.springframework.boot.web.reactive.error.ErrorAttributes; import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler; import org.springframework.http.HttpStatus; import org.springframework.security.access.AccessDeniedException; import org.springframework.web.reactive.function.server.ServerRequest; /** - * Maps connection exceptions to HTTP 503 status code instead of 500 + * Custom error attributes that remap specific exceptions to appropriate HTTP + * status codes. *

- * In the event that a route exists and the downstream service is not available, - * usually the Gateway returns a 503 status code as expected. + * This class extends {@link DefaultErrorAttributes} to modify error responses + * in a Spring WebFlux application. It ensures that: + *

    + *
  • {@link UnknownHostException} and {@link ConnectException} return an HTTP + * 503 ({@link HttpStatus#SERVICE_UNAVAILABLE}) instead of the default HTTP + * 500.
  • + *
  • {@link AccessDeniedException} results in an HTTP 403 + * ({@link HttpStatus#FORBIDDEN}).
  • + *
+ *

+ * *

- * On a dynamic environment though, such as k8s and docker compose, the - * underlying error results from a DNS lookup failure, and the default error is - * 500 instead. + * In dynamic environments like Kubernetes and Docker Compose, service + * unavailability may manifest as DNS resolution failures, which would normally + * result in HTTP 500. This class ensures that such failures correctly return + * HTTP 503. + *

+ * *

- * This {@link ErrorAttributes} overrides the {@link DefaultErrorAttributes} - * configured in {@link ErrorWebFluxAutoConfiguration} and injected to the - * {@link ErrorWebExceptionHandler}, and translates - * {@link java.net.UnknownHostException} and {@link java.net.ConnectException} - * to {@link HttpStatus#SERVICE_UNAVAILABLE} + * This class is injected into the {@link ErrorWebExceptionHandler} and replaces + * the default error handling provided by {@link ErrorWebFluxAutoConfiguration}. + *

+ * + * @see DefaultErrorAttributes + * @see ErrorWebFluxAutoConfiguration + * @see ErrorWebExceptionHandler */ public class CustomErrorAttributes extends DefaultErrorAttributes { + /** + * Overrides the default error attributes to remap specific exceptions to + * appropriate HTTP status codes. + *

+ * This method retrieves the original error attributes and modifies the status + * code for the following exceptions: + *

    + *
  • {@link UnknownHostException} and {@link ConnectException} → HTTP 503 + * (Service Unavailable)
  • + *
  • {@link AccessDeniedException} → HTTP 403 (Forbidden)
  • + *
+ *

+ * + *

+ * Example Modified Response: + *

+ * + *
+     * {
+     *   "status": 503,
+     *   "error": "Service Unavailable",
+     *   "message": "Upstream service unavailable"
+     * }
+     * 
+ * + * @param request the server request that caused the error + * @param options options for error attribute filtering + * @return a modified map of error attributes with updated status codes + */ @Override public Map getErrorAttributes(ServerRequest request, ErrorAttributeOptions options) { Map attributes = super.getErrorAttributes(request, options); Throwable error = super.getError(request); + if (error instanceof UnknownHostException || error instanceof ConnectException) { attributes.put("status", HttpStatus.SERVICE_UNAVAILABLE.value()); attributes.put("error", HttpStatus.SERVICE_UNAVAILABLE.getReasonPhrase()); @@ -60,7 +104,7 @@ public Map getErrorAttributes(ServerRequest request, ErrorAttrib attributes.put("status", HttpStatus.FORBIDDEN.value()); attributes.put("error", HttpStatus.FORBIDDEN.getReasonPhrase()); } + return attributes; } - -} \ No newline at end of file +} diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/ErrorCustomizerAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/ErrorCustomizerAutoConfiguration.java index 9da8bc46..d0de9f14 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/ErrorCustomizerAutoConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/ErrorCustomizerAutoConfiguration.java @@ -24,13 +24,40 @@ import org.springframework.context.annotation.Bean; /** - * Overrides the {@lin k DefaultErrorAttributes} configured in - * {@link ErrorWebFluxAutoConfiguration} and injected to the - * {@link ErrorWebExceptionHandler} + * Auto-configuration for customizing error handling in geOrchestra Gateway + *

+ * This configuration replaces the default error attributes provided by + * {@link ErrorWebFluxAutoConfiguration} with {@link CustomErrorAttributes}, + * which modifies error responses for specific exceptions. + *

+ * + *

+ * This ensures that certain network-related failures (e.g., DNS resolution + * errors) return an HTTP 503 (Service Unavailable) instead of HTTP 500. + *

+ * + *

+ * The customized error attributes are injected into the + * {@link ErrorWebExceptionHandler}, affecting how errors are represented in the + * application's responses. + *

+ * + * @see CustomErrorAttributes + * @see ErrorWebFluxAutoConfiguration + * @see ErrorWebExceptionHandler */ @AutoConfiguration(before = ErrorWebFluxAutoConfiguration.class) public class ErrorCustomizerAutoConfiguration { + /** + * Registers {@link CustomErrorAttributes} to override default error handling + *

+ * This bean ensures that network-related exceptions and access control errors + * are properly mapped to HTTP 503 and HTTP 403 respectively. + *

+ * + * @return an instance of {@link CustomErrorAttributes} for handling errors + */ @Bean CustomErrorAttributes customErrorAttributes() { return new CustomErrorAttributes(); diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/FiltersAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/FiltersAutoConfiguration.java index b0d90661..4b6ea08d 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/FiltersAutoConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/FiltersAutoConfiguration.java @@ -35,6 +35,24 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Import; +/** + * Auto-configuration for geOrchestra gateway filters and predicates. + *

+ * This configuration registers custom filters and predicates that enhance + * Spring Cloud Gateway's request handling capabilities. It ensures that + * necessary filters are available before {@link GatewayAutoConfiguration} is + * loaded. + *

+ * + *

+ * This class also imports {@link HeaderFiltersConfiguration} and enables + * configuration properties via {@link GatewayConfigProperties}. + *

+ * + * @see GatewayAutoConfiguration + * @see HeaderFiltersConfiguration + * @see GatewayConfigProperties + */ @AutoConfiguration @AutoConfigureBefore(GatewayAutoConfiguration.class) @Import(HeaderFiltersConfiguration.class) @@ -42,40 +60,71 @@ public class FiltersAutoConfiguration { /** - * {@link GlobalFilter} to {@link GeorchestraTargetConfig#setTarget save) the - * matched Route's GeorchestraTargetConfig for each HTTP request-response - * interaction before other filters are applied. + * Registers a {@link GlobalFilter} that resolves the target configuration for + * each incoming request. + *

+ * This filter extracts the matched route's {@link GeorchestraTargetConfig} and + * saves it for later processing in the request-response lifecycle. + *

+ * + * @param config the gateway configuration properties + * @return an instance of {@link ResolveTargetGlobalFilter} */ @Bean ResolveTargetGlobalFilter resolveTargetWebFilter(GatewayConfigProperties config) { return new ResolveTargetGlobalFilter(config); } + /** + * Registers a gateway filter factory that processes login-related query + * parameters. + * + * @return an instance of {@link LoginParamRedirectGatewayFilterFactory} + */ @Bean LoginParamRedirectGatewayFilterFactory loginParamRedirectGatewayFilterFactory() { return new LoginParamRedirectGatewayFilterFactory(); } /** - * Custom gateway predicate factory to support matching by regular expressions - * on both name and value of query parameters + * Registers a custom route predicate factory that allows matching query + * parameters based on regular expressions. + * + * @return an instance of {@link RegExpQueryRoutePredicateFactory} */ @Bean RegExpQueryRoutePredicateFactory regExpQueryRoutePredicateFactory() { return new RegExpQueryRoutePredicateFactory(); } - /** Allows to enable routes only if a given spring profile is enabled */ + /** + * Registers a gateway filter factory that enables or disables routes based on + * the active Spring profiles. + * + * @return an instance of {@link RouteProfileGatewayFilterFactory} + */ @Bean RouteProfileGatewayFilterFactory routeProfileGatewayFilterFactory() { return new RouteProfileGatewayFilterFactory(); } + /** + * Registers a gateway filter factory that strips the base path from incoming + * requests. + * + * @return an instance of {@link StripBasePathGatewayFilterFactory} + */ @Bean StripBasePathGatewayFilterFactory stripBasePathGatewayFilterFactory() { return new StripBasePathGatewayFilterFactory(); } + /** + * Registers a gateway filter factory that handles application-level errors + * gracefully. + * + * @return an instance of {@link ApplicationErrorGatewayFilterFactory} + */ @Bean ApplicationErrorGatewayFilterFactory applicationErrorGatewayFilterFactory() { return new ApplicationErrorGatewayFilterFactory(); diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/RoutePredicateFactoriesAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/RoutePredicateFactoriesAutoConfiguration.java index 8ee823fd..79ee635c 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/RoutePredicateFactoriesAutoConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/app/RoutePredicateFactoriesAutoConfiguration.java @@ -22,9 +22,27 @@ import org.springframework.boot.autoconfigure.AutoConfiguration; import org.springframework.context.annotation.Bean; +/** + * Auto-configuration for custom route predicate factories in geOrchestra + * Gateway. + *

+ * This configuration registers custom predicate factories that extend the + * routing capabilities of Spring Cloud Gateway. + *

+ */ @AutoConfiguration public class RoutePredicateFactoriesAutoConfiguration { + /** + * Registers a route predicate factory that allows matching requests based on + * query parameters. + *

+ * This predicate enables routing decisions based on the presence and values of + * query parameters in incoming requests. + *

+ * + * @return an instance of {@link QueryParamRoutePredicateFactory} + */ @Bean QueryParamRoutePredicateFactory queryParamRoutePredicateFactory() { return new QueryParamRoutePredicateFactory(); diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/AtLeastOneLdapDatasourceEnabledCondition.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/AtLeastOneLdapDatasourceEnabledCondition.java index 384d4dd9..9e322479 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/AtLeastOneLdapDatasourceEnabledCondition.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/AtLeastOneLdapDatasourceEnabledCondition.java @@ -43,17 +43,27 @@ import com.google.common.collect.Streams; /** - * {@link Condition} that matches if at least one LDAP config is enabled from - * the externalized config properties - * {@code georchestra.gateway.security.ldap..enabled} - * + * {@link Condition} that matches if at least one LDAP configuration is enabled. + *

+ * This condition checks the externalized configuration properties: + * {@code georchestra.gateway.security.ldap..enabled}. If at least + * one LDAP configuration is enabled, the condition evaluates to {@code true}. + *

+ * * @see GeorchestraGatewaySecurityConfigProperties */ class AtLeastOneLdapDatasourceEnabledCondition extends SpringBootCondition { + /** + * Determines whether the condition matches based on the presence of at least + * one enabled LDAP data source. + * + * @param context the condition evaluation context + * @param metadata the metadata of the annotated component + * @return a {@link ConditionOutcome} indicating whether the condition matches + */ @Override public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeMetadata metadata) { - Set enabledDatasourceNames = findEnabled(context); boolean anyEnabled = !enabledDatasourceNames.isEmpty(); @@ -62,20 +72,19 @@ public ConditionOutcome getMatchOutcome(ConditionContext context, AnnotatedTypeM } ItemsBuilder itemsBuilder = ConditionMessage.forCondition(ConditionalOnLdapEnabled.class) - .didNotFind("any enabled ldap config"); + .didNotFind("any enabled LDAP configuration"); - ConditionMessage message; - if (enabledDatasourceNames.isEmpty()) { - message = itemsBuilder.atAll(); - } else { - message = itemsBuilder.items(enabledDatasourceNames); - } + ConditionMessage message = enabledDatasourceNames.isEmpty() ? itemsBuilder.atAll() + : itemsBuilder.items(enabledDatasourceNames); return ConditionOutcome.noMatch(message); } /** - * @return the configured LDAP data source names that are enabled + * Finds enabled LDAP configurations based on environment properties. + * + * @param context the condition evaluation context + * @return a set of enabled LDAP data source names */ static Set findEnabled(ConditionContext context) { Environment environment = context.getEnvironment(); @@ -86,30 +95,30 @@ static Set findEnabled(ConditionContext context) { List propertyNames = findMatchingPropertyNames(propertySources, regex); Set names = new HashSet<>(); - for (String p : propertyNames) { - String value = environment.getProperty(p); + for (String property : propertyNames) { + String value = environment.getProperty(property); if (Boolean.parseBoolean(value)) { - Matcher matcher = pattern.matcher(p); + Matcher matcher = pattern.matcher(property); if (matcher.matches()) { - String name = matcher.group(1); - names.add(name); + names.add(matcher.group(1)); } } } return names; } + /** + * Retrieves property names that match the specified regular expression. + * + * @param propertySources the available property sources + * @param regex the regular expression to match property names + * @return a list of matching property names + */ static List findMatchingPropertyNames(MutablePropertySources propertySources, final String regex) { - final Pattern pattern = Pattern.compile(regex); final Predicate filter = pattern.asMatchPredicate(); - return Streams.stream(propertySources)// - .filter(EnumerablePropertySource.class::isInstance)// - .map(EnumerablePropertySource.class::cast)// - .map(EnumerablePropertySource::getPropertyNames)// - .flatMap(Stream::of)// - .filter(filter)// - .toList(); + return Streams.stream(propertySources).filter(EnumerablePropertySource.class::isInstance) + .map(EnumerablePropertySource.class::cast).map(EnumerablePropertySource::getPropertyNames) + .flatMap(Stream::of).filter(filter).toList(); } - } diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnHeaderPreAuthentication.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnHeaderPreAuthentication.java index dfbd381f..e7f565bd 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnHeaderPreAuthentication.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnHeaderPreAuthentication.java @@ -16,7 +16,6 @@ * You should have received a copy of the GNU General Public License along with * geOrchestra. If not, see . */ - package org.georchestra.gateway.autoconfigure.security; import java.lang.annotation.Documented; @@ -29,7 +28,14 @@ import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty; /** + * Condition that enables a bean if header-based pre-authentication is enabled. + *

+ * This annotation ensures that a component is only loaded when the + * {@code georchestra.gateway.security.preauth.enabled} property is set to + * {@code true}. + *

* + * @see HeaderPreauthConfigProperties */ @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnLdapEnabled.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnLdapEnabled.java index 6e4326ba..7ec0e3f0 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnLdapEnabled.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/ConditionalOnLdapEnabled.java @@ -16,7 +16,6 @@ * You should have received a copy of the GNU General Public License along with * geOrchestra. If not, see . */ - package org.georchestra.gateway.autoconfigure.security; import java.lang.annotation.Documented; @@ -30,7 +29,19 @@ import org.springframework.ldap.core.LdapTemplate; /** + * Condition that enables a bean if LDAP support is available and at least one + * LDAP data source is enabled. + *

+ * This annotation ensures that a component is only loaded when: + *

    + *
  • The {@link LdapTemplate} class is present on the classpath.
  • + *
  • At least one LDAP configuration is enabled, as determined by + * {@link AtLeastOneLdapDatasourceEnabledCondition}.
  • + *
+ *

* + * @see AtLeastOneLdapDatasourceEnabledCondition + * @see LdapTemplate */ @Target({ ElementType.TYPE, ElementType.METHOD }) @Retention(RetentionPolicy.RUNTIME) diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/HeaderPreAuthenticationAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/HeaderPreAuthenticationAutoConfiguration.java index cf3fcf02..c9c3db94 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/HeaderPreAuthenticationAutoConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/HeaderPreAuthenticationAutoConfiguration.java @@ -23,13 +23,18 @@ import org.springframework.context.annotation.Import; /** - * {@link AutoConfiguration @AutoConfiguration} to enable request headers - * pre-authentication. + * Auto-configuration for request headers pre-authentication. *

- * This feature shall be enabled through the - * {@code georchestra.gateway.security.header-authentication.enabled=true} - * config property. - * + * This configuration enables header-based pre-authentication when the + * {@code georchestra.gateway.security.header-authentication.enabled} property + * is set to {@code true}. + *

+ * + *

+ * It imports {@link HeaderPreAuthenticationConfiguration}, which provides the + * necessary beans for handling pre-authentication via request headers. + *

+ * * @see ConditionalOnHeaderPreAuthentication * @see HeaderPreAuthenticationConfiguration */ diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/LdapSecurityAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/LdapSecurityAutoConfiguration.java index fc71708c..895359b3 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/LdapSecurityAutoConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/LdapSecurityAutoConfiguration.java @@ -22,14 +22,27 @@ import org.georchestra.gateway.security.ldap.LdapAuthenticationConfiguration; import org.springframework.boot.autoconfigure.AutoConfiguration; -import org.springframework.boot.autoconfigure.EnableAutoConfiguration; import org.springframework.context.annotation.Import; import lombok.extern.slf4j.Slf4j; /** - * {@link EnableAutoConfiguration AutoConfiguration} to set up LDAP security - * + * Auto-configuration for LDAP-based authentication in geOrchestra. + *

+ * This configuration enables LDAP authentication when at least one LDAP data + * source is enabled and the required dependencies are available. + *

+ * + *

+ * It imports {@link LdapAuthenticationConfiguration}, which sets up the + * necessary beans for LDAP authentication. + *

+ * + *

+ * Upon initialization, this configuration logs a message indicating that LDAP + * authentication has been enabled. + *

+ * * @see LdapAuthenticationConfiguration */ @AutoConfiguration @@ -38,7 +51,11 @@ @Slf4j(topic = "org.georchestra.gateway.autoconfigure.security") public class LdapSecurityAutoConfiguration { - public @PostConstruct void log() { + /** + * Logs a message when LDAP security is enabled. + */ + @PostConstruct + public void log() { log.info("georchestra LDAP security enabled"); } } diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/OAuth2SecurityAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/OAuth2SecurityAutoConfiguration.java index 9c157d66..74f7588b 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/OAuth2SecurityAutoConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/OAuth2SecurityAutoConfiguration.java @@ -28,23 +28,66 @@ import lombok.extern.slf4j.Slf4j; +/** + * Auto-configuration for OAuth2-based authentication in geOrchestra. + *

+ * This configuration conditionally enables or disables OAuth2 security based on + * the property {@code georchestra.gateway.security.oauth2.enabled}. + *

+ * + *

+ * It imports either: + *

    + *
  • {@link Enabled} when OAuth2 is enabled.
  • + *
  • {@link Disabled} when OAuth2 is disabled (default).
  • + *
+ *

+ * + * @see OAuth2Configuration + */ @AutoConfiguration @Slf4j(topic = "org.georchestra.gateway.autoconfigure.security") @Import({ OAuth2SecurityAutoConfiguration.Enabled.class, OAuth2SecurityAutoConfiguration.Disabled.class }) public class OAuth2SecurityAutoConfiguration { + private static final String ENABLED_PROP = "georchestra.gateway.security.oauth2.enabled"; + /** + * Configuration that enables OAuth2 security when explicitly enabled. + *

+ * This configuration is loaded if + * {@code georchestra.gateway.security.oauth2.enabled} is set to {@code true}. + *

+ */ @Import(OAuth2Configuration.class) @ConditionalOnProperty(name = ENABLED_PROP, havingValue = "true", matchIfMissing = false) static @Configuration class Enabled { - public @PostConstruct void log() { + + /** + * Logs a message indicating that OAuth2 security is enabled. + */ + @PostConstruct + public void log() { log.info("georchestra OAuth2 security enabled"); } } + /** + * Configuration that disables OAuth2 security by default. + *

+ * This configuration is loaded if + * {@code georchestra.gateway.security.oauth2.enabled} is set to {@code false} + * or is missing. + *

+ */ @ConditionalOnProperty(name = ENABLED_PROP, havingValue = "false", matchIfMissing = true) static @Configuration class Disabled { - public @PostConstruct void log() { + + /** + * Logs a message indicating that OAuth2 security is disabled. + */ + @PostConstruct + public void log() { log.info("georchestra OAuth2 security disabled"); } } diff --git a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/WebSecurityAutoConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/WebSecurityAutoConfiguration.java index 5a4ff71a..bdac034c 100644 --- a/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/WebSecurityAutoConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/autoconfigure/security/WebSecurityAutoConfiguration.java @@ -24,6 +24,28 @@ import org.springframework.boot.autoconfigure.security.ConditionalOnDefaultWebSecurity; import org.springframework.context.annotation.Import; +/** + * Auto-configuration for web security in geOrchestra Gateway. + *

+ * This configuration is applied only when Spring Security's default web + * security is enabled, as determined by + * {@link ConditionalOnDefaultWebSecurity}. + *

+ * + *

+ * It imports: + *

    + *
  • {@link GatewaySecurityConfiguration} - Configures security settings for + * the gateway.
  • + *
  • {@link AccessRulesConfiguration} - Manages access rules and security + * policies.
  • + *
+ *

+ * + * @see GatewaySecurityConfiguration + * @see AccessRulesConfiguration + * @see ConditionalOnDefaultWebSecurity + */ @AutoConfiguration @ConditionalOnDefaultWebSecurity @Import({ GatewaySecurityConfiguration.class, AccessRulesConfiguration.class }) diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactory.java b/gateway/src/main/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactory.java index 770ec931..d4d1d8cc 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactory.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/global/ApplicationErrorGatewayFilterFactory.java @@ -38,17 +38,20 @@ import reactor.core.publisher.Mono; /** - * Filter to allow custom error pages to be used when an application behind the - * gateways returns an error, only for idempotent HTTP response status codes - * (i.e. GET, HEAD, OPTIONS). + * Gateway filter that enables custom error pages when a proxied application + * responds with an error status, applicable only for idempotent HTTP methods + * (e.g., GET, HEAD, OPTIONS). *

- * {@link GatewayFilterFactory} providing a {@link GatewayFilter} that throws a - * {@link ResponseStatusException} with the proxied response status code if the - * target responded with a {@code 400...} or {@code 500...} status code. - * + * This {@link GatewayFilterFactory} provides a {@link GatewayFilter} that + * throws a {@link ResponseStatusException} with the response status code if the + * proxied service returns a {@code 400...} or {@code 500...} status. The + * gateway will then apply its custom error handling. + *

*

- * Usage: to enable it globally, add this to application.yaml : - * + * Usage: To enable this filter globally, add the following to + * {@code application.yaml}: + *

+ * *
  * 
  * spring:
@@ -58,10 +61,12 @@
  *        - ApplicationError
  * 
  * 
- * - * To enable it only on some routes, add this to concerned routes in - * {@literal routes.yaml}: - * + * + *

+ * To enable it only for specific routes, configure the filter in + * {@code routes.yaml}: + *

+ * *
  * 
  *        filters:
@@ -81,11 +86,22 @@ public GatewayFilter apply(final Object config) {
         return new ServiceErrorGatewayFilter();
     }
 
+    /**
+     * Gateway filter that intercepts error responses and triggers a
+     * {@link ResponseStatusException} to allow the gateway to render a custom error
+     * page.
+     */
     private class ServiceErrorGatewayFilter implements GatewayFilter, Ordered {
+
         /**
-         * @return {@link Ordered#HIGHEST_PRECEDENCE} or
-         *         {@link ApplicationErrorConveyorHttpResponse#beforeCommit(Supplier)}
-         *         won't be called
+         * Returns the order of this filter to ensure it runs at the highest precedence.
+         * 

+ * This is necessary so that + * {@link ApplicationErrorConveyorHttpResponse#beforeCommit(Supplier)} gets + * executed properly. + *

+ * + * @return {@link Ordered#HIGHEST_PRECEDENCE} */ @Override public int getOrder() { @@ -93,11 +109,18 @@ public int getOrder() { } /** - * If the request method is idempotent and accepts {@literal text/html}, applies - * a filter that when the routed response receives an error status code, will - * throw a {@link ResponseStatusException} with the same status, for the gateway - * to apply the customized error template, also when the status code comes from - * a proxied service response + * Applies the filter logic by wrapping the response in a decorator that checks + * for error statuses. + *

+ * If the request method is idempotent and the request accepts + * {@code text/html}, the response is wrapped in a + * {@link ApplicationErrorConveyorHttpResponse}, which throws a + * {@link ResponseStatusException} if an error status code is encountered. + *

+ * + * @param exchange the current server exchange + * @param chain the gateway filter chain + * @return a {@link Mono} that completes when the filter chain is executed */ @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { @@ -108,16 +131,37 @@ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { } } + /** + * Wraps the server exchange's response with an + * {@link ApplicationErrorConveyorHttpResponse} to intercept error statuses. + * + * @param exchange the server exchange to decorate + * @return a new {@link ServerWebExchange} instance with the decorated response + */ ServerWebExchange decorate(ServerWebExchange exchange) { var response = new ApplicationErrorConveyorHttpResponse(exchange.getResponse()); exchange = exchange.mutate().response(response).build(); return exchange; } + /** + * Determines if the request should be filtered based on method idempotency and + * accepted content types. + * + * @param request the incoming HTTP request + * @return {@code true} if the request should be filtered, {@code false} + * otherwise + */ boolean canFilter(ServerHttpRequest request) { return methodIsIdempotent(request.getMethod()) && acceptsHtml(request); } + /** + * Checks if the request method is idempotent (i.e., does not modify state). + * + * @param method the HTTP method to check + * @return {@code true} if the method is idempotent, {@code false} otherwise + */ boolean methodIsIdempotent(HttpMethod method) { return switch (method) { case GET, HEAD, OPTIONS, TRACE -> true; @@ -125,15 +169,21 @@ boolean methodIsIdempotent(HttpMethod method) { }; } + /** + * Determines whether the request accepts HTML responses. + * + * @param request the incoming HTTP request + * @return {@code true} if the request accepts {@code text/html}, {@code false} + * otherwise + */ boolean acceptsHtml(ServerHttpRequest request) { return request.getHeaders().getAccept().stream().anyMatch(MediaType.TEXT_HTML::isCompatibleWith); } /** - * A response decorator that throws a {@link ResponseStatusException} at - * {@link #beforeCommit} if the status code is an error code, thus letting the - * gateway render the appropriate custom error page instead of the original - * application response body. + * A response decorator that throws a {@link ResponseStatusException} in + * {@link #beforeCommit} if the status code is an error, allowing the gateway to + * handle the error with a custom response page. */ private static class ApplicationErrorConveyorHttpResponse extends ServerHttpResponseDecorator { @@ -148,6 +198,10 @@ public void beforeCommit(Supplier> action) { super.beforeCommit(() -> checkedAction); } + /** + * Throws a {@link ResponseStatusException} if the response status is in the 4xx + * or 5xx range, allowing the gateway to apply custom error handling. + */ private void checkStatusCode() { HttpStatus statusCode = getStatusCode(); log.debug("native status code: {}", statusCode); diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/global/LoginParamRedirectGatewayFilterFactory.java b/gateway/src/main/java/org/georchestra/gateway/filter/global/LoginParamRedirectGatewayFilterFactory.java index 3856a80f..f5c89de7 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/global/LoginParamRedirectGatewayFilterFactory.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/global/LoginParamRedirectGatewayFilterFactory.java @@ -48,12 +48,18 @@ import reactor.core.publisher.Mono; /** - * {@link GatewayFilterFactory} that redirects to {@literal /login} if the - * request's query string contains a {@literal login} parameter and the request - * is not already authenticated. + * {@link GatewayFilterFactory} that redirects unauthenticated requests to + * {@literal /login} when the query string contains a {@literal login} + * parameter. *

- * Sample usage: - * + * This filter applies only to idempotent HTTP methods (GET, HEAD, OPTIONS, + * TRACE) and ensures that authenticated users proceed without redirection. + *

+ *

+ * Usage: Add the following to {@code application.yaml} to enable this + * filter for specific routes: + *

+ * *
  * 
  * spring:
@@ -66,12 +72,11 @@
  *        - LoginParamRedirect
  * 
  * 
- * */ @Slf4j public class LoginParamRedirectGatewayFilterFactory extends AbstractGatewayFilterFactory { - private static final Set redirectMethods = Set.of(GET, HEAD, OPTIONS, TRACE); + private static final Set REDIRECT_METHODS = Set.of(GET, HEAD, OPTIONS, TRACE); @Override public LoginParamRedirectGatewayFilter apply(Object config) { @@ -82,6 +87,10 @@ public LoginParamRedirectGatewayFilter apply(Object config) { return new LoginParamRedirectGatewayFilter(delegate); } + /** + * Gateway filter that applies redirection logic when an unauthenticated request + * contains a {@code login} query parameter. + */ @RequiredArgsConstructor public static class LoginParamRedirectGatewayFilter implements GatewayFilter { @@ -90,10 +99,24 @@ public static class LoginParamRedirectGatewayFilter implements GatewayFilter { private final @NonNull GatewayFilter delegate; + /** + * Intercepts requests and redirects to {@code /login} if: + *
    + *
  • The HTTP method is idempotent (GET, HEAD, OPTIONS, TRACE)
  • + *
  • The request contains a {@code login} query parameter
  • + *
  • The user is not authenticated
  • + *
+ * If the user is already authenticated, the request proceeds without + * redirection. + * + * @param exchange the current server exchange + * @param chain the gateway filter chain + * @return a {@link Mono} that completes when the filter chain is executed + */ @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { HttpMethod method = exchange.getRequest().getMethod(); - if (redirectMethods.contains(method) && containsLoginQueryParam(exchange)) { + if (REDIRECT_METHODS.contains(method) && containsLoginQueryParam(exchange)) { log.info("Applying ?login query param redirect filter for {} {}", method, exchange.getRequest().getURI()); return redirectToLoginIfNotAuthenticated(exchange, chain); @@ -101,33 +124,50 @@ public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { return chain.filter(exchange); } + /** + * Redirects the user to {@code /login} if they are not authenticated. + * + * @param exchange the server exchange + * @param chain the gateway filter chain + * @return a {@link Mono} that either redirects to {@code /login} or proceeds + * with the request if already authenticated + */ private Mono redirectToLoginIfNotAuthenticated(ServerWebExchange exchange, GatewayFilterChain chain) { - return getAuthentication()// .filter(Authentication::isAuthenticated)// .switchIfEmpty(Mono.just(UNAUTHENTICATED))// .flatMap(authentication -> { - // delegate to the redirect filter otherwise if (authentication instanceof AnonymousAuthenticationToken) { - log.info("redirecting to /login: {}", exchange.getRequest().getURI()); + log.info("Redirecting to /login: {}", exchange.getRequest().getURI()); return delegate.filter(exchange, chain); } - // proceed if already authenticated - log.info("already authenticated ({}), proceeding without redirection to /login", + log.info("Already authenticated ({}), proceeding without redirection to /login", authentication.getName()); return chain.filter(exchange); }); } + /** + * Retrieves the current authentication context. + * + * @return a {@link Mono} containing the {@link Authentication} object, or an + * empty Mono if unavailable + */ @VisibleForTesting public Mono getAuthentication() { return ReactiveSecurityContextHolder.getContext().map(SecurityContext::getAuthentication); } + /** + * Checks if the request contains a {@code login} query parameter. + * + * @param exchange the server exchange + * @return {@code true} if the query parameter is present, {@code false} + * otherwise + */ private boolean containsLoginQueryParam(ServerWebExchange exchange) { ServerHttpRequest request = exchange.getRequest(); return request.getQueryParams().containsKey("login"); } - } } diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/global/ResolveTargetGlobalFilter.java b/gateway/src/main/java/org/georchestra/gateway/filter/global/ResolveTargetGlobalFilter.java index 9458eed2..deee655f 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/global/ResolveTargetGlobalFilter.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/global/ResolveTargetGlobalFilter.java @@ -46,38 +46,51 @@ import reactor.core.publisher.Mono; /** - * A {@link GlobalFilter} that resolves the {@link GeorchestraTargetConfig - * configuration} for the request's matched {@link Route} and - * {@link GeorchestraTargetConfig#setTarget stores} it to be - * {@link GeorchestraTargetConfig#getTarget acquired} by non-global filters as - * needed. + * A {@link GlobalFilter} that resolves and stores the + * {@link GeorchestraTargetConfig} for the matched {@link Route}, enabling + * subsequent filters to access configuration details such as role-based access + * rules and HTTP header mappings. + *

+ * This filter executes after user resolution in + * {@link ResolveGeorchestraUserGlobalFilter} and before request routing in + * {@link RouteToRequestUrlFilter}. + *

*/ @RequiredArgsConstructor @Slf4j public class ResolveTargetGlobalFilter implements GlobalFilter, Ordered { + /** + * The execution order of this filter, ensuring it runs after user resolution + * but before request routing. + */ public static final int ORDER = ResolveGeorchestraUserGlobalFilter.ORDER + 1; private final @NonNull GatewayConfigProperties config; /** - * @return a lower precedence than {@link RouteToRequestUrlFilter}'s, in order - * to make sure the matched {@link Route} has been set as a - * {@link ServerWebExchange#getAttributes attribute} when - * {@link #filter} is called. + * Ensures that this filter runs after the matched {@link Route} has been set as + * an attribute in the {@link ServerWebExchange}. + * + * @return the execution order of this filter */ - public @Override int getOrder() { + @Override + public int getOrder() { return ResolveTargetGlobalFilter.ORDER; } /** - * Resolves the matched {@link Route} and its corresponding - * {@link GeorchestraTargetConfig}, if possible, and proceeds with the filter - * chain. + * Resolves the {@link GeorchestraTargetConfig} for the matched {@link Route} + * and stores it in the request exchange attributes. + * + * @param exchange the current server exchange + * @param chain the gateway filter chain + * @return a {@link Mono} that proceeds with the filter chain execution */ - public @Override Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { Route route = (Route) exchange.getAttributes().get(GATEWAY_ROUTE_ATTR); - Objects.requireNonNull(route, "no route matched, filter shouldn't be hit"); + Objects.requireNonNull(route, "No route matched, filter should not be executed"); GeorchestraTargetConfig targetConfig = resolveTarget(route); log.debug("Storing geOrchestra target config for Route {} request context", route.getId()); @@ -85,28 +98,54 @@ public class ResolveTargetGlobalFilter implements GlobalFilter, Ordered { return chain.filter(exchange); } + /** + * Resolves the {@link GeorchestraTargetConfig} for the given route by applying + * the service-specific or global access rules and header mappings. + * + * @param route the matched route + * @return a {@link GeorchestraTargetConfig} containing the relevant + * configuration + */ @VisibleForTesting @NonNull GeorchestraTargetConfig resolveTarget(@NonNull Route route) { - GeorchestraTargetConfig target = new GeorchestraTargetConfig(); Optional service = findService(route); - setAccessRules(target, service); setHeaderMappings(target, service); return target; } + /** + * Determines the applicable access rules for the target configuration. + *

+ * If the matched service defines access rules, they are applied; otherwise, the + * global access rules from {@link GatewayConfigProperties} are used. + *

+ * + * @param target the target configuration to update + * @param service the matched service, if available + */ private void setAccessRules(GeorchestraTargetConfig target, Optional service) { List globalAccessRules = config.getGlobalAccessRules(); - var targetAccessRules = service.map(Service::getAccessRules).filter(Objects::nonNull).filter(l -> !l.isEmpty()) - .orElse(globalAccessRules); + List targetAccessRules = service.map(Service::getAccessRules).filter(Objects::nonNull) + .filter(l -> !l.isEmpty()).orElse(globalAccessRules); target.accessRules(targetAccessRules); } + /** + * Determines the applicable HTTP header mappings for the target configuration. + *

+ * If the matched service defines custom header mappings, they are merged with + * the global default headers. Otherwise, only the global defaults are applied. + *

+ * + * @param target the target configuration to update + * @param service the matched service, if available + */ private void setHeaderMappings(GeorchestraTargetConfig target, Optional service) { HeaderMappings defaultHeaders = config.getDefaultHeaders(); HeaderMappings mergedHeaders = service.flatMap(Service::headers) @@ -115,10 +154,24 @@ private void setHeaderMappings(GeorchestraTargetConfig target, Optional target.headers(mergedHeaders); } + /** + * Merges the default global headers with service-specific headers. + * + * @param defaults the global default headers + * @param service the service-specific headers + * @return a merged {@link HeaderMappings} instance + */ private HeaderMappings merge(HeaderMappings defaults, HeaderMappings service) { return defaults.copy().merge(service); } + /** + * Finds the matching service definition for the given route. + * + * @param route the matched route + * @return an {@link Optional} containing the matched {@link Service}, or empty + * if not found + */ private Optional findService(@NonNull Route route) { final URI routeURI = route.getUri(); @@ -128,8 +181,6 @@ private Optional findService(@NonNull Route route) { return Optional.of(service); } } - return Optional.empty(); } - -} \ No newline at end of file +} diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/AddSecHeadersGatewayFilterFactory.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/AddSecHeadersGatewayFilterFactory.java index 17c23382..b15ee2ec 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/AddSecHeadersGatewayFilterFactory.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/AddSecHeadersGatewayFilterFactory.java @@ -33,39 +33,97 @@ import lombok.RequiredArgsConstructor; import reactor.core.publisher.Mono; +/** + * {@link AbstractGatewayFilterFactory} that adds geOrchestra-specific security + * headers to proxied requests. + *

+ * This filter allows customizable security headers to be appended to requests, + * using a set of {@link HeaderContributor} providers. If the request exchange + * contains the attribute {@link #DISABLE_SECURITY_HEADERS}, the filter is + * bypassed. + *

+ *

+ * Sample usage in {@code application.yaml} to apply the filter globally: + *

+ * + *
+ * 
+ * spring:
+ *   cloud:
+ *     gateway:
+ *       default-filters:
+ *         - AddSecHeaders
+ * 
+ * 
+ */ public class AddSecHeadersGatewayFilterFactory extends AbstractGatewayFilterFactory { + /** + * Attribute key to disable the security headers for a specific request. If this + * attribute is present in the request exchange, the filter is skipped. + */ public static final String DISABLE_SECURITY_HEADERS = "%s.DISABLE_SECURITY_HEADERS" .formatted(AddSecHeadersGatewayFilterFactory.class.getName()); private final List providers; + /** + * Creates a new instance of the security headers filter factory. + * + * @param providers the list of {@link HeaderContributor} providers that + * generate the security headers + */ public AddSecHeadersGatewayFilterFactory(List providers) { super(NameConfig.class); this.providers = providers; } - public @Override List shortcutFieldOrder() { + /** + * Defines the order of configuration fields for shortcut configuration in + * {@code application.yaml}. + * + * @return the ordered list of configuration field names + */ + @Override + public List shortcutFieldOrder() { return Arrays.asList(NAME_KEY); } - public @Override GatewayFilter apply(NameConfig config) { + /** + * Creates a new {@link GatewayFilter} instance with the configured providers. + * + * @param config the filter configuration + * @return the configured {@link GatewayFilter} + */ + @Override + public GatewayFilter apply(NameConfig config) { return new AddSecHeadersGatewayFilter(providers); } + /** + * {@link GatewayFilter} implementation that applies the configured security + * headers to proxied requests. + */ @RequiredArgsConstructor private static class AddSecHeadersGatewayFilter implements GatewayFilter, Ordered { private final @NonNull List providers; - public @Override Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + /** + * Applies the configured security headers to the request unless the + * {@link #DISABLE_SECURITY_HEADERS} attribute is present. + * + * @param exchange the current server exchange + * @param chain the gateway filter chain + * @return a {@link Mono} that proceeds with the filter chain execution + */ + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { if (exchange.getAttribute(DISABLE_SECURITY_HEADERS) == null) { ServerHttpRequest.Builder requestBuilder = exchange.getRequest().mutate(); - providers.stream()// - .map(provider -> provider.prepare(exchange))// - .forEach(requestBuilder::headers); + providers.stream().map(provider -> provider.prepare(exchange)).forEach(requestBuilder::headers); ServerHttpRequest request = requestBuilder.build(); ServerWebExchange updatedExchange = exchange.mutate().request(request).build(); @@ -74,10 +132,15 @@ private static class AddSecHeadersGatewayFilter implements GatewayFilter, Ordere return chain.filter(exchange); } + /** + * Specifies the execution order of this filter to run immediately after + * {@link ResolveTargetGlobalFilter}. + * + * @return the execution order of this filter + */ @Override public int getOrder() { return ResolveTargetGlobalFilter.ORDER + 1; } } - } diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/CookieAffinityGatewayFilterFactory.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/CookieAffinityGatewayFilterFactory.java index 1073ea8d..6d922e14 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/CookieAffinityGatewayFilterFactory.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/CookieAffinityGatewayFilterFactory.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2023 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ package org.georchestra.gateway.filter.headers; import javax.validation.constraints.NotEmpty; @@ -16,30 +34,99 @@ import lombok.Setter; import reactor.core.publisher.Mono; +/** + * {@link AbstractGatewayFilterFactory} that modifies the path of a specific + * HTTP response cookie, enabling cookie-based session affinity between + * different backend services. + *

+ * This filter allows rewriting the cookie's path from a specified {@code from} + * path to a different {@code to} path. The original domain, security settings, + * and expiration remain unchanged. + *

+ *

+ * Sample usage in {@code application.yaml} to apply this filter on specific + * routes: + *

+ * + *
+ * 
+ * spring:
+ *   cloud:
+ *     gateway:
+ *       routes:
+ *       - id: some-service
+ *         uri: http://backend-service
+ *         filters:
+ *         - name: CookieAffinity
+ *           args:
+ *             name: JSESSIONID
+ *             from: /serviceA
+ *             to: /serviceB
+ * 
+ * 
+ */ public class CookieAffinityGatewayFilterFactory extends AbstractGatewayFilterFactory { + + /** + * Creates a new instance of the cookie affinity filter factory. + */ public CookieAffinityGatewayFilterFactory() { super(CookieAffinityGatewayFilterFactory.CookieAffinity.class); } + /** + * Creates a {@link GatewayFilter} that applies the cookie path transformation. + * + * @param config the filter configuration + * @return the configured {@link GatewayFilter} + */ @Override public GatewayFilter apply(final CookieAffinityGatewayFilterFactory.CookieAffinity config) { return new CookieAffinityGatewayFilter(config); } + /** + * Configuration class for {@link CookieAffinityGatewayFilterFactory}. Defines + * the cookie name and path mapping. + */ @Validated public static class CookieAffinity { + + /** + * The name of the cookie to modify. + */ private @NotEmpty @Getter @Setter String name; + + /** + * The original path of the cookie. + */ private @NotEmpty @Getter @Setter String from; + + /** + * The new path to which the cookie should be rewritten. + */ private @NotEmpty @Getter @Setter String to; } + /** + * {@link GatewayFilter} implementation that modifies the path of a specific + * cookie in the response headers. + */ @RequiredArgsConstructor private static class CookieAffinityGatewayFilter implements GatewayFilter, Ordered { private final CookieAffinity config; - public @Override Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + /** + * Processes the response to update the path of the specified cookie. + * + * @param exchange the current server exchange + * @param chain the gateway filter chain + * @return a {@link Mono} that proceeds with the filter chain execution + */ + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { return chain.filter(exchange).then(Mono.fromRunnable(() -> { exchange.getResponse().getHeaders().getValuesAsList("Set-Cookie").stream() .flatMap(c -> java.net.HttpCookie.parse(c).stream()) @@ -54,6 +141,12 @@ private static class CookieAffinityGatewayFilter implements GatewayFilter, Order })); } + /** + * Specifies the execution order of this filter to run immediately after + * {@link ResolveTargetGlobalFilter}. + * + * @return the execution order of this filter + */ @Override public int getOrder() { return ResolveTargetGlobalFilter.ORDER + 1; diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderContributor.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderContributor.java index 1e5c03a9..b4c14a05 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderContributor.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderContributor.java @@ -25,44 +25,61 @@ import org.georchestra.gateway.filter.headers.providers.GeorchestraOrganizationHeadersContributor; import org.georchestra.gateway.filter.headers.providers.GeorchestraUserHeadersContributor; +import org.georchestra.gateway.filter.headers.providers.JsonPayloadHeadersContributor; import org.georchestra.gateway.filter.headers.providers.SecProxyHeaderContributor; import org.springframework.core.Ordered; import org.springframework.http.HttpHeaders; -import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.web.server.ServerWebExchange; import lombok.NonNull; import lombok.extern.slf4j.Slf4j; /** - * Extension point to aid {@link AddSecHeadersGatewayFilterFactory} in appending - * the required HTTP request headers to proxied requests. + * Strategy interface for contributing HTTP request headers to proxied requests. *

- * Beans of this type are strategy objects that contribute zero or more HTTP - * request headers to be appended to proxied requests to back-end services. - * - * @see SecProxyHeaderContributor - * @see GeorchestraUserHeadersContributor - * @see GeorchestraOrganizationHeadersContributor + * Implementations of this class define specific security headers that should be + * appended to proxied requests based on authentication and request context. + *

+ *

+ * These implementations are used by {@link AddSecHeadersGatewayFilterFactory} + * to determine which headers should be added. + *

+ * + *

Implementations

+ *
    + *
  • {@link SecProxyHeaderContributor}: Appends the {@code sec-proxy} + * header
  • + *
  • {@link GeorchestraUserHeadersContributor}: Adds user-related security + * headers
  • + *
  • {@link GeorchestraOrganizationHeadersContributor}: Appends organization + * information headers
  • + *
  • {@link JsonPayloadHeadersContributor}: Encodes security attributes as a + * JSON payload
  • + *
+ * + * @see AddSecHeadersGatewayFilterFactory */ @Slf4j(topic = "org.georchestra.gateway.filter.headers") public abstract class HeaderContributor implements Ordered { /** - * Prepare a header contributor for the given HTTP request-response interaction. + * Prepares a consumer that modifies {@link HttpHeaders} for a proxied request. *

- * The returned consumer will {@link HttpHeaders#set(String, String) set} or - * {@link HttpHeaders#add(String, String) add} whatever request headers are - * appropriate for the backend service. + * Implementations should return a consumer that either sets or appends headers + * based on the security context and request attributes. + *

+ * + * @param exchange the current {@link ServerWebExchange} + * @return a {@link Consumer} that modifies the request headers */ public abstract Consumer prepare(ServerWebExchange exchange); /** * {@inheritDoc} - * - * @return {@code 0} as default order, implementations should override as needed - * in case they need to apply their customizations to - * {@link ServerHttpSecurity} in a specific order. + * + * @return {@code 0} as the default order. Implementations may override this + * method to control execution order when multiple contributors are + * applied. * @see Ordered#HIGHEST_PRECEDENCE * @see Ordered#LOWEST_PRECEDENCE */ @@ -70,21 +87,46 @@ public abstract class HeaderContributor implements Ordered { return 0; } + /** + * Appends a header to the request if it is enabled and has a valid value. + * + * @param target the target {@link HttpHeaders} + * @param header the header name + * @param enabled whether the header should be included + * @param value the header value + */ protected void add(@NonNull HttpHeaders target, @NonNull String header, @NonNull Optional enabled, @NonNull Optional value) { add(target, header, enabled, value.orElse(null)); } + /** + * Appends a header to the request if it is enabled and has a valid list of + * values. + * + * @param target the target {@link HttpHeaders} + * @param header the header name + * @param enabled whether the header should be included + * @param values the list of header values + */ protected void add(@NonNull HttpHeaders target, @NonNull String header, @NonNull Optional enabled, @NonNull List values) { String val = values.isEmpty() ? null : values.stream().collect(Collectors.joining(";")); add(target, header, enabled, val); } + /** + * Appends a header to the request if it is enabled and has a valid value. + * + * @param target the target {@link HttpHeaders} + * @param header the header name + * @param enabled whether the header should be included + * @param value the header value + */ protected void add(@NonNull HttpHeaders target, @NonNull String header, @NonNull Optional enabled, String value) { - if (enabled.orElse(Boolean.FALSE).booleanValue()) { - if (null == value) { + if (enabled.orElse(Boolean.FALSE)) { + if (value == null) { log.trace("Value for header {} is not present", header); } else { log.debug("Appending header {}: {}", header, value); @@ -95,8 +137,15 @@ protected void add(@NonNull HttpHeaders target, @NonNull String header, @NonNull } } + /** + * Appends a header to the request if it has a valid value. + * + * @param target the target {@link HttpHeaders} + * @param header the header name + * @param value the header value + */ protected void add(@NonNull HttpHeaders target, @NonNull String header, String value) { - if (null == value) { + if (value == null) { log.trace("Value for header {} is not present", header); } else { log.debug("Appending header {}: {}", header, value); diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderFiltersConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderFiltersConfiguration.java index fe30e6f2..bb1966dc 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderFiltersConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/HeaderFiltersConfiguration.java @@ -32,55 +32,100 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * {@link Configuration} for security-related header filters in the gateway. + *

+ * This configuration defines various {@link GatewayFilterFactory} beans for + * handling geOrchestra security headers in proxied requests. + *

+ * + * @see AddSecHeadersGatewayFilterFactory + * @see RemoveHeadersGatewayFilterFactory + * @see RemoveSecurityHeadersGatewayFilterFactory + */ @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties(GatewayConfigProperties.class) public class HeaderFiltersConfiguration { /** - * {@link GatewayFilterFactory} to add all necessary {@literal sec-*} request + * {@link GatewayFilterFactory} to append all necessary {@code sec-*} request * headers to proxied requests. - * + * * @param providers the list of configured {@link HeaderContributor}s in the * {@link ApplicationContext} - * @see #secProxyHeaderProvider() + * @return the configured {@link AddSecHeadersGatewayFilterFactory} + * @see #secProxyHeaderProvider(GatewayConfigProperties) * @see #userSecurityHeadersProvider() * @see #organizationSecurityHeadersProvider() + * @see #jsonPayloadHeadersContributor() */ - @Bean AddSecHeadersGatewayFilterFactory addSecHeadersGatewayFilterFactory(List providers) { return new AddSecHeadersGatewayFilterFactory(providers); } + /** + * {@link GatewayFilterFactory} that modifies the affinity of a cookie by + * rewriting its path. + * + * @return the configured {@link CookieAffinityGatewayFilterFactory} + */ @Bean CookieAffinityGatewayFilterFactory cookieAffinityGatewayFilterFactory() { return new CookieAffinityGatewayFilterFactory(); } + /** + * {@link HeaderContributor} that appends geOrchestra user-related {@code sec-*} + * request headers. + * + * @return the configured {@link GeorchestraUserHeadersContributor} + */ @Bean GeorchestraUserHeadersContributor userSecurityHeadersProvider() { return new GeorchestraUserHeadersContributor(); } + /** + * {@link HeaderContributor} that appends the {@code sec-proxy} request header + * when enabled in the gateway configuration. + * + * @param configProps the gateway security configuration properties + * @return the configured {@link SecProxyHeaderContributor} + */ @Bean SecProxyHeaderContributor secProxyHeaderProvider(GatewayConfigProperties configProps) { BooleanSupplier secProxyEnabledSupplier = () -> configProps.getDefaultHeaders().getProxy().orElse(false); return new SecProxyHeaderContributor(secProxyEnabledSupplier); } + /** + * {@link HeaderContributor} that appends geOrchestra organization-related + * {@code sec-*} request headers. + * + * @return the configured {@link GeorchestraOrganizationHeadersContributor} + */ @Bean GeorchestraOrganizationHeadersContributor organizationSecurityHeadersProvider() { return new GeorchestraOrganizationHeadersContributor(); } + /** + * {@link HeaderContributor} that appends {@code sec-user} and + * {@code sec-organization} Base64-encoded JSON payloads. + * + * @return the configured {@link JsonPayloadHeadersContributor} + */ @Bean JsonPayloadHeadersContributor jsonPayloadHeadersContributor() { return new JsonPayloadHeadersContributor(); } /** - * General purpose {@link GatewayFilterFactory} to remove incoming HTTP request - * headers based on a Java regular expression + * General-purpose {@link GatewayFilterFactory} to remove incoming HTTP request + * headers based on a Java regular expression. + * + * @return the configured {@link RemoveHeadersGatewayFilterFactory} */ @Bean RemoveHeadersGatewayFilterFactory removeHeadersGatewayFilterFactory() { @@ -88,8 +133,10 @@ RemoveHeadersGatewayFilterFactory removeHeadersGatewayFilterFactory() { } /** - * {@link GatewayFilterFactory} to remove incoming HTTP {@literal sec-*} HTTP - * request headers to prevent impersonation from outside + * {@link GatewayFilterFactory} to remove incoming {@code sec-*} HTTP request + * headers to prevent impersonation from external sources. + * + * @return the configured {@link RemoveSecurityHeadersGatewayFilterFactory} */ @Bean RemoveSecurityHeadersGatewayFilterFactory removeSecurityHeadersGatewayFilterFactory() { diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveHeadersGatewayFilterFactory.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveHeadersGatewayFilterFactory.java index 196e967e..bb0195b8 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveHeadersGatewayFilterFactory.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveHeadersGatewayFilterFactory.java @@ -19,7 +19,6 @@ package org.georchestra.gateway.filter.headers; import java.util.Arrays; -import java.util.HashSet; import java.util.List; import java.util.Map; import java.util.Objects; @@ -41,12 +40,18 @@ * {@link GatewayFilterFactory} to remove incoming HTTP request headers whose * names match a Java regular expression. *

- * Use a {@code RemoveHeaders=} filter in a - * {@code spring.cloud.gateway.routes.filters} route config to remove all - * incoming request headers matching the regex. + * This filter ensures that unwanted headers are stripped from incoming requests + * before being forwarded to backend services, improving security and request + * integrity. + *

*

- * Sample usage: - * + * Usage: + *

+ *

+ * Add a {@code RemoveHeaders=} filter to a route in the + * {@code spring.cloud.gateway.routes.filters} configuration. + *

+ * *
  * 
  * spring:
@@ -59,7 +64,26 @@
  *        - RemoveHeaders=(?i)(sec-.*|Authorization) 
  * 
  * 
- * + * + *

+ * Since version {@code 1.2.0}, the regular expression can match both header + * names and values. This allows filtering specific header values while + * preserving others. For example, to strip Basic authentication headers but + * keep Bearer tokens, the following configuration can be used: + *

+ * + *
+ * 
+ * spring:
+ *   cloud:
+ *    gateway:
+ *      routes:
+ *      - id: root
+ *        uri: http://backend-service/context
+ *        filters:
+ *        - RemoveHeaders=(?i)^(sec-.*|Authorization:(?!\s*Bearer\s*$))
+ * 
+ * 
*/ @Slf4j(topic = "org.georchestra.gateway.filter.headers") public class RemoveHeadersGatewayFilterFactory extends AbstractGatewayFilterFactory { @@ -76,55 +100,95 @@ public List shortcutFieldOrder() { @Override public GatewayFilter apply(RegExConfig regexConfig) { return (exchange, chain) -> { - final RegExConfig config = regexConfig;// == null ? DEFAULT_SECURITY_HEADERS_CONFIG : regexConfig; - - ServerHttpRequest request = exchange.getRequest().mutate().headers(config::removeMatching).build(); + ServerHttpRequest request = exchange.getRequest().mutate().headers(regexConfig::removeMatching).build(); exchange = exchange.mutate().request(request).build(); - return chain.filter(exchange); }; } + /** + * Configuration class that holds the regular expression for header removal. + *

+ * The provided regular expression is compiled and used to match both header + * names and values. Headers that match the pattern are removed from incoming + * requests before they are forwarded. + *

+ */ @NoArgsConstructor public static class RegExConfig { private @Getter String regEx; - private Pattern compiled; + /** + * Constructs a {@link RegExConfig} with the given regular expression. + * + * @param regEx the regular expression for matching headers to remove + */ public RegExConfig(String regEx) { setRegEx(regEx); } + /** + * Sets the regular expression used to match header names and values. + * + * @param regEx the regular expression to use + */ public void setRegEx(String regEx) { - Objects.requireNonNull(regEx, "regular expression can't be null"); + Objects.requireNonNull(regEx, "Regular expression can't be null"); this.regEx = regEx; this.compiled = Pattern.compile(regEx); } private Pattern pattern() { - Objects.requireNonNull(compiled, "regular expression can't be null"); + Objects.requireNonNull(compiled, "Regular expression is not initialized"); return compiled; } + /** + * Checks if any headers in the given {@link HttpHeaders} match the configured + * regular expression. + * + * @param httpHeaders the HTTP headers to check + * @return {@code true} if any headers match, otherwise {@code false} + */ boolean anyMatches(@NonNull HttpHeaders httpHeaders) { - return httpHeaders.keySet().stream().anyMatch(h -> this.matches(h, httpHeaders.get(h))); + return httpHeaders.keySet().stream().anyMatch(header -> matches(header, httpHeaders.get(header))); } + /** + * Checks if a given header name or its value matches the configured regular + * expression. + * + * @param headerNameOrTuple the header name or header name-value pair + * @return {@code true} if it matches, otherwise {@code false} + */ boolean matches(@NonNull String headerNameOrTuple) { return pattern().matcher(headerNameOrTuple).matches(); } + /** + * Checks if a given header name and its values match the configured regular + * expression. + * + * @param headerName the name of the header + * @param values the list of header values + * @return {@code true} if any value matches, otherwise {@code false} + */ boolean matches(@NonNull String headerName, List values) { return values.stream().map(value -> "%s: %s".formatted(headerName, value)).anyMatch(this::matches); } + /** + * Removes all headers from the given {@link HttpHeaders} that match the + * configured regular expression. + * + * @param headers the HTTP headers from which matching headers should be removed + */ void removeMatching(@NonNull HttpHeaders headers) { - List.copyOf(headers.entrySet()).stream().filter(e -> matches(e.getKey()))// - .filter(e -> matches(e.getKey(), e.getValue())).map(Map.Entry::getKey)// - .peek(name -> log.trace("Removing header {}", name))// + List.copyOf(headers.entrySet()).stream().filter(entry -> matches(entry.getKey(), entry.getValue())) + .map(Map.Entry::getKey).peek(name -> log.trace("Removing header {}", name)) .forEach(headers::remove); } } - } diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveSecurityHeadersGatewayFilterFactory.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveSecurityHeadersGatewayFilterFactory.java index 57d1a76d..dea0a67d 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveSecurityHeadersGatewayFilterFactory.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/RemoveSecurityHeadersGatewayFilterFactory.java @@ -23,12 +23,26 @@ import org.springframework.cloud.gateway.filter.factory.GatewayFilterFactory; /** - * Georchestra-specific {@link GatewayFilterFactory} to remove all incoming - * {@code sec-*} and {@code Authorization} (basic auth) request headers, hence - * preventing impersonating geOrchestra authenticated users from incoming - * requests. + * A geOrchestra-specific {@link GatewayFilterFactory} that removes all incoming + * security-related request headers, preventing unauthorized impersonation of + * authenticated users. *

- * Sample usage: + * This filter is designed to strip: + *

+ *
    + *
  • All headers prefixed with {@code sec-*}, which geOrchestra uses for + * user-related security information.
  • + *
  • The {@code Authorization} header when it contains Basic authentication + * credentials.
  • + *
+ *

+ * By removing these headers from incoming requests, the gateway ensures that + * authentication and authorization are enforced properly and prevents external + * clients from injecting unauthorized credentials. + *

+ *

+ * Usage example: + *

* *
  * 
@@ -53,11 +67,23 @@ public class RemoveSecurityHeadersGatewayFilterFactory extends AbstractGatewayFi
     private final RemoveHeadersGatewayFilterFactory.RegExConfig config = new RemoveHeadersGatewayFilterFactory.RegExConfig(
             DEFAULT_SEC_HEADERS_PATTERN);
 
+    /**
+     * Creates a new instance of {@code RemoveSecurityHeadersGatewayFilterFactory}
+     * that removes security-sensitive headers from incoming requests.
+     */
     public RemoveSecurityHeadersGatewayFilterFactory() {
         super(Object.class);
         delegate = new RemoveHeadersGatewayFilterFactory();
     }
 
+    /**
+     * Applies the filter by delegating to {@link RemoveHeadersGatewayFilterFactory}
+     * with a pre-configured regular expression that matches security-related
+     * headers.
+     * 
+     * @param unused the configuration object (not used)
+     * @return a {@link GatewayFilter} instance that removes security headers
+     */
     @Override
     public GatewayFilter apply(Object unused) {
         return delegate.apply(config);
diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraOrganizationHeadersContributor.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraOrganizationHeadersContributor.java
index 1c3a3e91..9e80b7ab 100644
--- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraOrganizationHeadersContributor.java
+++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraOrganizationHeadersContributor.java
@@ -28,18 +28,44 @@
 import org.springframework.http.HttpHeaders;
 import org.springframework.web.server.ServerWebExchange;
 
+/**
+ * {@link HeaderContributor} that appends organization-related security headers
+ * to proxied requests.
+ * 

+ * This contributor extracts organization information from the current request + * context and applies the configured security headers based on + * {@link GeorchestraTargetConfig}. + *

+ * + *

Appended Headers

+ *
    + *
  • {@code sec-orgname} - Organization name
  • + *
  • {@code sec-orgid} - Organization ID
  • + *
  • {@code sec-org-lastupdated} - Last updated timestamp of the + * organization
  • + *
+ */ public class GeorchestraOrganizationHeadersContributor extends HeaderContributor { + /** + * Prepares a header contributor that appends organization-related security + * headers to the request. + *

+ * Headers are only added if the organization is resolved from the request and + * the corresponding configuration enables them. + *

+ * + * @param exchange the current {@link ServerWebExchange} + * @return a {@link Consumer} that modifies the request headers + */ public @Override Consumer prepare(ServerWebExchange exchange) { return headers -> { - GeorchestraTargetConfig.getTarget(exchange)// - .map(GeorchestraTargetConfig::headers)// - .ifPresent(mappings -> { - Optional org = GeorchestraOrganizations.resolve(exchange); - add(headers, "sec-orgname", mappings.getOrgname(), org.map(Organization::getName)); - add(headers, "sec-orgid", mappings.getOrgid(), org.map(Organization::getId)); - add(headers, "sec-org-lastupdated", mappings.getOrgid(), org.map(Organization::getLastUpdated)); - }); + GeorchestraTargetConfig.getTarget(exchange).map(GeorchestraTargetConfig::headers).ifPresent(mappings -> { + Optional org = GeorchestraOrganizations.resolve(exchange); + add(headers, "sec-orgname", mappings.getOrgname(), org.map(Organization::getName)); + add(headers, "sec-orgid", mappings.getOrgid(), org.map(Organization::getId)); + add(headers, "sec-org-lastupdated", mappings.getOrgid(), org.map(Organization::getLastUpdated)); + }); }; } } diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraUserHeadersContributor.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraUserHeadersContributor.java index dc8f354d..342385e6 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraUserHeadersContributor.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/GeorchestraUserHeadersContributor.java @@ -45,16 +45,48 @@ import org.springframework.web.server.ServerWebExchange; /** - * Contributes user-related {@literal sec-*} request headers. - * - * @see GeorchestraUsers#resolve - * @see GeorchestraTargetConfig + * {@link HeaderContributor} that appends user-related {@literal sec-*} security + * headers to proxied requests. + *

+ * This contributor extracts user information from the current request context + * and applies the configured security headers based on + * {@link GeorchestraTargetConfig}. + *

+ * + *

Appended Headers

+ *
    + *
  • {@code sec-userid} - User ID
  • + *
  • {@code sec-username} - Username
  • + *
  • {@code sec-org} - Organization
  • + *
  • {@code sec-email} - Email address
  • + *
  • {@code sec-firstname} - First name
  • + *
  • {@code sec-lastname} - Last name
  • + *
  • {@code sec-tel} - Telephone number
  • + *
  • {@code sec-roles} - List of user roles
  • + *
  • {@code sec-lastupdated} - Last updated timestamp
  • + *
  • {@code sec-address} - Postal address
  • + *
  • {@code sec-title} - User title
  • + *
  • {@code sec-notes} - Notes
  • + *
  • {@code sec-ldap-remaining-days} - LDAP password expiration warning
  • + *
  • {@code sec-external-authentication} - Whether the user is authenticated + * externally
  • + *
*/ public class GeorchestraUserHeadersContributor extends HeaderContributor { + /** + * Prepares a header contributor that appends user-related security headers to + * the request. + *

+ * Headers are only added if the user is resolved from the request and the + * corresponding configuration enables them. + *

+ * + * @param exchange the current {@link ServerWebExchange} + * @return a {@link Consumer} that modifies the request headers + */ public @Override Consumer prepare(ServerWebExchange exchange) { - return headers -> GeorchestraTargetConfig.getTarget(exchange)// - .map(GeorchestraTargetConfig::headers)// + return headers -> GeorchestraTargetConfig.getTarget(exchange).map(GeorchestraTargetConfig::headers) .ifPresent(mappings -> { Optional user = GeorchestraUsers.resolve(exchange); add(headers, SEC_USERID, mappings.getUserid(), user.map(GeorchestraUser::getId)); @@ -66,19 +98,19 @@ public class GeorchestraUserHeadersContributor extends HeaderContributor { add(headers, SEC_TEL, mappings.getTel(), user.map(GeorchestraUser::getTelephoneNumber)); List roles = user.map(GeorchestraUser::getRoles).orElse(List.of()); - add(headers, SEC_ROLES, mappings.getRoles(), roles); add(headers, SEC_LASTUPDATED, mappings.getLastUpdated(), user.map(GeorchestraUser::getLastUpdated)); add(headers, SEC_ADDRESS, mappings.getAddress(), user.map(GeorchestraUser::getPostalAddress)); add(headers, SEC_TITLE, mappings.getTitle(), user.map(GeorchestraUser::getTitle)); add(headers, SEC_NOTES, mappings.getNotes(), user.map(GeorchestraUser::getNotes)); + add(headers, SEC_LDAP_REMAINING_DAYS, - Optional.of( - user.isPresent() && user.get().getLdapWarn() != null && user.get().getLdapWarn()), + Optional.of(user.isPresent() && Boolean.TRUE.equals(user.get().getLdapWarn())), user.map(GeorchestraUser::getLdapRemainingDays)); + add(headers, SEC_EXTERNAL_AUTHENTICATION, Optional.of(user.isPresent()), - String.valueOf(user.isPresent() && user.get().getIsExternalAuth())); + String.valueOf(user.isPresent() && Boolean.TRUE.equals(user.get().getIsExternalAuth()))); }); } } diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/JsonPayloadHeadersContributor.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/JsonPayloadHeadersContributor.java index f72b1e36..59be43a7 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/JsonPayloadHeadersContributor.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/JsonPayloadHeadersContributor.java @@ -23,7 +23,6 @@ import java.util.function.Consumer; import org.georchestra.commons.security.SecurityHeaders; -import org.georchestra.ds.security.OrganizationsApiImpl; import org.georchestra.gateway.filter.headers.HeaderContributor; import org.georchestra.gateway.model.GeorchestraOrganizations; import org.georchestra.gateway.model.GeorchestraTargetConfig; @@ -40,11 +39,24 @@ import com.fasterxml.jackson.databind.SerializationFeature; /** - * Contributes {@literal sec-user} and {@literal sec-organization} - * Base64-encoded JSON payloads, based on {@link HeaderMappings#getJsonUser()} - * and {@link HeaderMappings#getJsonOrganization()} matched-route headers - * configuration. - * + * {@link HeaderContributor} that appends user and organization information as + * Base64-encoded JSON payloads to proxied requests. + *

+ * This contributor enables the following security headers based on the matched + * route configuration: + *

+ *
    + *
  • {@code sec-user} - Contains a Base64-encoded JSON representation of the + * authenticated {@link GeorchestraUser}.
  • + *
  • {@code sec-organization} - Contains a Base64-encoded JSON representation + * of the resolved {@link Organization}.
  • + *
+ *

+ * The encoding process ensures the data is included only when explicitly + * enabled via {@link HeaderMappings#getJsonUser()} and + * {@link HeaderMappings#getJsonOrganization()}. + *

+ * * @see GeorchestraUsers#resolve * @see GeorchestraOrganizations#resolve * @see GeorchestraTargetConfig @@ -52,35 +64,45 @@ public class JsonPayloadHeadersContributor extends HeaderContributor { /** - * Encoder to create the JSON String value for a {@link GeorchestraUser} - * obtained from {@link OrganizationsApiImpl} + * JSON encoder for serializing {@link GeorchestraUser} and {@link Organization} + * objects. */ - private ObjectMapper encoder; + private final ObjectMapper encoder; + /** + * Initializes a new {@link JsonPayloadHeadersContributor} with a configured + * JSON encoder. + */ public JsonPayloadHeadersContributor() { this.encoder = new ObjectMapper(); - this.encoder.configure(SerializationFeature.INDENT_OUTPUT, Boolean.FALSE); - this.encoder.configure(SerializationFeature.WRITE_SINGLE_ELEM_ARRAYS_UNWRAPPED, Boolean.FALSE); + this.encoder.configure(SerializationFeature.INDENT_OUTPUT, false); + this.encoder.configure(SerializationFeature.WRITE_SINGLE_ELEM_ARRAYS_UNWRAPPED, false); this.encoder.setSerializationInclusion(Include.NON_NULL); } + /** + * Prepares a header contributor that appends JSON-based security headers for + * the request. + * + * @param exchange the current {@link ServerWebExchange} + * @return a {@link Consumer} that modifies the request headers + */ public @Override Consumer prepare(ServerWebExchange exchange) { - return headers -> GeorchestraTargetConfig.getTarget(exchange)// - .map(GeorchestraTargetConfig::headers)// + return headers -> GeorchestraTargetConfig.getTarget(exchange).map(GeorchestraTargetConfig::headers) .ifPresent(mappings -> addJsonPayloads(exchange, mappings, headers)); } private void addJsonPayloads(final ServerWebExchange exchange, final HeaderMappings mappings, HttpHeaders headers) { Optional user = GeorchestraUsers.resolve(exchange); Optional org = GeorchestraOrganizations.resolve(exchange); + addJson(headers, "sec-user", mappings.getJsonUser().orElse(false), user); addJson(headers, "sec-organization", mappings.getJsonOrganization().orElse(false), org); } private void addJson(HttpHeaders target, String headerName, boolean enabled, Optional toEncode) { if (enabled) { - toEncode.map(this::encodeJson)// - .map(this::encodeBase64)// + toEncode.map(this::encodeJson).map(this::encodeBase64) .ifPresent(encoded -> target.add(headerName, encoded)); } } diff --git a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/SecProxyHeaderContributor.java b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/SecProxyHeaderContributor.java index 5990c3b0..269186c9 100644 --- a/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/SecProxyHeaderContributor.java +++ b/gateway/src/main/java/org/georchestra/gateway/filter/headers/providers/SecProxyHeaderContributor.java @@ -29,19 +29,36 @@ import lombok.RequiredArgsConstructor; /** - * Contributes the {@literal sec-proxy: true} request header based on the value - * returned by the provided {@link BooleanSupplier}, which is required by all - * backend services as a flag indicating the request is authenticated. + * {@link HeaderContributor} that appends the {@code sec-proxy: true} request + * header to indicate that the request is authenticated through the gateway. + *

+ * This header is required by all backend services to differentiate between + * authenticated and unauthenticated requests. + *

+ *

+ * The contribution of this header is controlled by a {@link BooleanSupplier}, + * allowing dynamic enablement based on external conditions. + *

+ * + * @see HeaderContributor */ @RequiredArgsConstructor public class SecProxyHeaderContributor extends HeaderContributor { private final @NonNull BooleanSupplier secProxyHeaderEnabled; + /** + * Prepares a header contributor that appends the {@code sec-proxy} header if + * enabled. + * + * @param exchange the current {@link ServerWebExchange} + * @return a {@link Consumer} that modifies the request headers + */ public @Override Consumer prepare(ServerWebExchange exchange) { return headers -> { - if (secProxyHeaderEnabled.getAsBoolean()) + if (secProxyHeaderEnabled.getAsBoolean()) { add(headers, "sec-proxy", "true"); + } }; } } diff --git a/gateway/src/main/java/org/georchestra/gateway/handler/predicate/QueryParamRoutePredicateFactory.java b/gateway/src/main/java/org/georchestra/gateway/handler/predicate/QueryParamRoutePredicateFactory.java index d089c063..b34d8b8b 100644 --- a/gateway/src/main/java/org/georchestra/gateway/handler/predicate/QueryParamRoutePredicateFactory.java +++ b/gateway/src/main/java/org/georchestra/gateway/handler/predicate/QueryParamRoutePredicateFactory.java @@ -30,62 +30,98 @@ import org.springframework.web.server.ServerWebExchange; /** - * URI predicate filter based on the existence of a given query parameter + * A route predicate factory that evaluates whether an HTTP request contains a + * specified query parameter. *

- * Usage: + * This predicate allows routing based on the presence of a query parameter in + * the request URI. + *

+ *

+ * Usage example: + *

* *
- *  
- * {@code
- * - id: 
- *   uri: 
+ * 
+ * - id: example-route
+ *   uri: http://example.com
  *   predicates:
- *    - QueryParam=
- * }
+ *    - QueryParam=token
+ * 
  * 
+ *

+ * The above configuration will route requests to {@code http://example.com} + * only if the query string contains the parameter {@code token}. + *

*/ public class QueryParamRoutePredicateFactory extends AbstractRoutePredicateFactory { public static final String PARAM_KEY = "param"; + /** + * Constructs a new {@code QueryParamRoutePredicateFactory}. + */ public QueryParamRoutePredicateFactory() { super(QueryParamRoutePredicateFactory.Config.class); } + /** + * Specifies the order of the fields when using the shortcut configuration. + * + * @return a list containing the expected field order + */ @Override public List shortcutFieldOrder() { return Arrays.asList(PARAM_KEY); } + /** + * Applies the predicate filter to check for the presence of the configured + * query parameter in the request. + * + * @param config the predicate configuration containing the query parameter name + * @return a {@link Predicate} that tests whether the request contains the + * specified query parameter + */ @Override public Predicate apply(QueryParamRoutePredicateFactory.Config config) { return new GatewayPredicate() { @Override public boolean test(ServerWebExchange exchange) { - String param = config.param; - if (exchange.getRequest().getQueryParams().containsKey(param)) { - return true; - } - return false; + return exchange.getRequest().getQueryParams().containsKey(config.param); } - public @Override String toString() { + @Override + public String toString() { return String.format("Query: param=%s", config.getParam()); } }; } + /** + * Configuration class for {@code QueryParamRoutePredicateFactory}. + */ @Validated public static class Config { @NotEmpty private String param; + /** + * Retrieves the configured query parameter name. + * + * @return the name of the query parameter + */ public String getParam() { return param; } + /** + * Sets the query parameter name to check for. + * + * @param param the query parameter name + * @return this {@code Config} instance for method chaining + */ public QueryParamRoutePredicateFactory.Config setParam(String param) { this.param = param; return this; diff --git a/gateway/src/main/java/org/georchestra/gateway/model/GatewayConfigProperties.java b/gateway/src/main/java/org/georchestra/gateway/model/GatewayConfigProperties.java index fef1284e..2afbafcd 100644 --- a/gateway/src/main/java/org/georchestra/gateway/model/GatewayConfigProperties.java +++ b/gateway/src/main/java/org/georchestra/gateway/model/GatewayConfigProperties.java @@ -28,33 +28,39 @@ import lombok.Generated; /** - * Model object representing the externalized configuration properties used to - * set up URI based access rules and HTTP request headers appended to proxied - * requests to back-end services. - * + * Configuration properties for the geOrchestra Gateway. + *

+ * This model object represents the externalized configuration used to define + * URI-based access rules and HTTP request headers appended to proxied requests + * to back-end services. + *

*/ @Data @Generated @ConfigurationProperties("georchestra.gateway") public class GatewayConfigProperties { + /** + * Role mappings that define additional roles granted to users based on their + * existing roles. + */ private Map> rolesMappings = Map.of(); /** - * Configures the global security headers to append to all proxied http requests + * Default security headers to append to all proxied HTTP requests. */ private HeaderMappings defaultHeaders = new HeaderMappings(); /** - * Incoming request URI pattern matching for requests that don't match any of - * the service-specific rules under - * {@literal georchestra.gateway.services.[service].access-rules} + * Global access rules that apply to requests that do not match any + * service-specific rules under + * {@code georchestra.gateway.services.[service].access-rules}. */ private List globalAccessRules = List.of(); /** - * Maps a logical service name to its back-end service URL and security settings + * Maps logical service names to their corresponding back-end service URLs and + * security settings. */ private Map services = Collections.emptyMap(); - } diff --git a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraOrganizations.java b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraOrganizations.java index b1d96e5e..dc69e9df 100644 --- a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraOrganizations.java +++ b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraOrganizations.java @@ -25,17 +25,42 @@ import lombok.experimental.UtilityClass; +/** + * Utility class for handling geOrchestra organization attributes within a + * {@link ServerWebExchange}. + *

+ * This class provides methods to store and retrieve an {@link Organization} + * instance associated with an exchange. + *

+ */ @UtilityClass public class GeorchestraOrganizations { + /** + * Attribute key used to store the organization in the exchange. + */ static final String GEORCHESTRA_ORGANIZATION_KEY = GeorchestraOrganizations.class.getCanonicalName(); + /** + * Retrieves the stored {@link Organization} from the exchange, if available. + * + * @param exchange the {@link ServerWebExchange} containing the attributes + * @return an {@link Optional} containing the stored organization, or empty if + * none exists + */ public static Optional resolve(ServerWebExchange exchange) { return Optional.ofNullable(exchange.getAttributes().get(GEORCHESTRA_ORGANIZATION_KEY)) .map(Organization.class::cast); } + /** + * Stores an {@link Organization} instance in the exchange attributes. + * + * @param exchange the {@link ServerWebExchange} where the organization should + * be stored + * @param org the {@link Organization} instance to store + */ public static void store(ServerWebExchange exchange, Organization org) { exchange.getAttributes().put(GEORCHESTRA_ORGANIZATION_KEY, org); } -} \ No newline at end of file +} diff --git a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraTargetConfig.java b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraTargetConfig.java index 99533dd1..e8d1ece2 100644 --- a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraTargetConfig.java +++ b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraTargetConfig.java @@ -29,24 +29,53 @@ import lombok.experimental.Accessors; /** - * The HTTP request headers and role-based access rules of a matched - * {@link Route} + * Represents the security and HTTP request header settings for a matched + * {@link Route}. + *

+ * This class defines role-based access rules and headers to be applied to + * proxied requests for a given route. + *

*/ @Data @Generated @Accessors(fluent = true, chain = true) public class GeorchestraTargetConfig { + /** + * Attribute key used to store the target configuration in the exchange. + */ private static final String TARGET_CONFIG_KEY = GeorchestraTargetConfig.class.getCanonicalName() + ".target"; + /** + * HTTP request headers to append when forwarding requests. + */ private HeaderMappings headers; + + /** + * Role-based access rules for controlling request authorization. + */ private List accessRules; + /** + * Retrieves the stored {@link GeorchestraTargetConfig} from the exchange, if + * available. + * + * @param exchange the {@link ServerWebExchange} containing the attributes + * @return an {@link Optional} containing the stored target configuration, or + * empty if none exists + */ public static Optional getTarget(ServerWebExchange exchange) { return Optional.ofNullable(exchange.getAttributes().get(TARGET_CONFIG_KEY)) .map(GeorchestraTargetConfig.class::cast); } + /** + * Stores a {@link GeorchestraTargetConfig} instance in the exchange attributes. + * + * @param exchange the {@link ServerWebExchange} where the configuration should + * be stored + * @param config the {@link GeorchestraTargetConfig} instance to store + */ public static void setTarget(ServerWebExchange exchange, GeorchestraTargetConfig config) { exchange.getAttributes().put(TARGET_CONFIG_KEY, config); } diff --git a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraUsers.java b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraUsers.java index e9ef3756..6a9b642e 100644 --- a/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraUsers.java +++ b/gateway/src/main/java/org/georchestra/gateway/model/GeorchestraUsers.java @@ -27,15 +27,44 @@ import lombok.NonNull; import lombok.experimental.UtilityClass; +/** + * Utility class for managing geOrchestra user attributes within a + * {@link ServerWebExchange}. + *

+ * This class provides methods to store and retrieve a {@link GeorchestraUser} + * instance associated with an exchange. + *

+ */ @UtilityClass public class GeorchestraUsers { + /** + * Attribute key used to store the geOrchestra user in the exchange. + */ static final String GEORCHESTRA_USER_KEY = GeorchestraUsers.class.getCanonicalName(); + /** + * Retrieves the stored {@link GeorchestraUser} from the exchange, if available. + * + * @param exchange the {@link ServerWebExchange} containing the attributes + * @return an {@link Optional} containing the stored user, or empty if none + * exists + */ public static Optional resolve(ServerWebExchange exchange) { return Optional.ofNullable(exchange.getAttributes().get(GEORCHESTRA_USER_KEY)).map(GeorchestraUser.class::cast); } + /** + * Stores a {@link GeorchestraUser} instance in the exchange attributes. + *

+ * If the provided user is {@code null}, the attribute is removed. + *

+ * + * @param exchange the {@link ServerWebExchange} where the user should be stored + * @param user the {@link GeorchestraUser} instance to store, or + * {@code null} to remove it + * @return the updated {@link ServerWebExchange} instance + */ public static ServerWebExchange store(@NonNull ServerWebExchange exchange, GeorchestraUser user) { Map attributes = exchange.getAttributes(); if (user == null) { diff --git a/gateway/src/main/java/org/georchestra/gateway/model/HeaderMappings.java b/gateway/src/main/java/org/georchestra/gateway/model/HeaderMappings.java index e80c6172..587fbab5 100644 --- a/gateway/src/main/java/org/georchestra/gateway/model/HeaderMappings.java +++ b/gateway/src/main/java/org/georchestra/gateway/model/HeaderMappings.java @@ -26,87 +26,112 @@ import lombok.Generated; /** - * Models which geOrchestra-specific HTTP request headers to append to proxied - * requests. + * Configuration model for geOrchestra-specific HTTP request headers. + *

+ * This class defines which security-related headers should be appended to + * proxied requests. Each header can be individually enabled or disabled. + *

*/ @Data @Generated public class HeaderMappings { + ///////// User info headers /////////////// - /** Append the standard {@literal sec-proxy=true} header to proxied requests */ + /** Append the standard {@literal sec-proxy=true} header to proxied requests. */ private Optional proxy = Optional.empty(); - /** Append the standard {@literal sec-userid} header to proxied requests */ + /** Append the standard {@literal sec-userid} header to proxied requests. */ private Optional userid = Optional.empty(); - /** Append the standard {@literal sec-lastupdated} header to proxied requests */ + /** + * Append the standard {@literal sec-lastupdated} header to proxied requests. + */ private Optional lastUpdated = Optional.empty(); - /** Append the standard {@literal sec-username} header to proxied requests */ + /** Append the standard {@literal sec-username} header to proxied requests. */ private Optional username = Optional.empty(); - /** Append the standard {@literal sec-roles} header to proxied requests */ + /** Append the standard {@literal sec-roles} header to proxied requests. */ private Optional roles = Optional.empty(); - /** Append the standard {@literal sec-org} header to proxied requests */ + /** Append the standard {@literal sec-org} header to proxied requests. */ private Optional org = Optional.empty(); - /** Append the standard {@literal sec-email} header to proxied requests */ + /** Append the standard {@literal sec-email} header to proxied requests. */ private Optional email = Optional.empty(); - /** Append the standard {@literal sec-firstname} header to proxied requests */ + /** Append the standard {@literal sec-firstname} header to proxied requests. */ private Optional firstname = Optional.empty(); - /** Append the standard {@literal sec-lastname} header to proxied requests */ + /** Append the standard {@literal sec-lastname} header to proxied requests. */ private Optional lastname = Optional.empty(); - /** Append the standard {@literal sec-tel} header to proxied requests */ + /** Append the standard {@literal sec-tel} header to proxied requests. */ private Optional tel = Optional.empty(); - /** Append the standard {@literal sec-address} header to proxied requests */ + /** Append the standard {@literal sec-address} header to proxied requests. */ private Optional address = Optional.empty(); - /** Append the standard {@literal sec-title} header to proxied requests */ + /** Append the standard {@literal sec-title} header to proxied requests. */ private Optional title = Optional.empty(); - /** Append the standard {@literal sec-notes} header to proxied requests */ + /** Append the standard {@literal sec-notes} header to proxied requests. */ private Optional notes = Optional.empty(); + /** * Append the standard {@literal sec-user} (Base64 JSON payload) header to - * proxied requests + * proxied requests. */ private Optional jsonUser = Optional.empty(); ///////// Organization info headers /////////////// - /** Append the standard {@literal sec-orgname} header to proxied requests */ + /** Append the standard {@literal sec-orgname} header to proxied requests. */ private Optional orgname = Optional.empty(); - /** Append the standard {@literal sec-orgid} header to proxied requests */ + /** Append the standard {@literal sec-orgid} header to proxied requests. */ private Optional orgid = Optional.empty(); /** - * Append the standard {@literal sec-org-lastupdated} header to proxied requests + * Append the standard {@literal sec-org-lastupdated} header to proxied + * requests. */ private Optional orgLastUpdated = Optional.empty(); /** * Append the standard {@literal sec-organization} (Base64 JSON payload) header - * to proxied requests + * to proxied requests. */ private Optional jsonOrganization = Optional.empty(); - public @VisibleForTesting HeaderMappings enableAll() { + /** + * Enables all headers. + * + * @return this instance with all headers set to {@code true} + */ + @VisibleForTesting + public HeaderMappings enableAll() { this.setAll(Optional.of(Boolean.TRUE)); return this; } - public @VisibleForTesting HeaderMappings disableAll() { + /** + * Disables all headers. + * + * @return this instance with all headers set to {@code false} + */ + @VisibleForTesting + public HeaderMappings disableAll() { this.setAll(Optional.of(Boolean.FALSE)); return this; } + /** + * Sets all header options to the given value. + * + * @param val the value to set for all headers + */ private void setAll(Optional val) { this.proxy = val; this.userid = val; @@ -128,23 +153,43 @@ private void setAll(Optional val) { this.jsonOrganization = val; } + /** + * Enables or disables the {@literal sec-userid} header. + * + * @param b {@code true} to enable, {@code false} to disable + * @return this instance with the updated configuration + */ public HeaderMappings userid(boolean b) { setUserid(Optional.of(b)); return this; } + /** + * Enables or disables the {@literal sec-user} (Base64 JSON) header. + * + * @param b {@code true} to enable, {@code false} to disable + * @return this instance with the updated configuration + */ public HeaderMappings jsonUser(boolean b) { setJsonUser(Optional.of(b)); return this; } + /** + * Enables or disables the {@literal sec-organization} (Base64 JSON) header. + * + * @param b {@code true} to enable, {@code false} to disable + * @return this instance with the updated configuration + */ public HeaderMappings jsonOrganization(boolean b) { setJsonOrganization(Optional.of(b)); return this; } /** - * @return a copy of this object + * Creates a copy of this object. + * + * @return a new {@link HeaderMappings} instance with the same values */ public HeaderMappings copy() { HeaderMappings copy = new HeaderMappings(); @@ -153,7 +198,10 @@ public HeaderMappings copy() { } /** - * Applies the non-empty fields from {@code other} to this one, and returns this + * Merges the non-empty fields from {@code other} into this instance. + * + * @param other the other {@link HeaderMappings} instance + * @return this instance with the merged values */ public HeaderMappings merge(HeaderMappings other) { proxy = merge(proxy, other.proxy); @@ -177,6 +225,13 @@ public HeaderMappings merge(HeaderMappings other) { return this; } + /** + * Merges two {@link Optional} values, preferring the second if present. + * + * @param a the first value + * @param b the second value (preferred if present) + * @return the merged {@link Optional} value + */ private Optional merge(Optional a, Optional b) { return b.isEmpty() ? a : b; } diff --git a/gateway/src/main/java/org/georchestra/gateway/model/RoleBasedAccessRule.java b/gateway/src/main/java/org/georchestra/gateway/model/RoleBasedAccessRule.java index 292de2b7..36602db6 100644 --- a/gateway/src/main/java/org/georchestra/gateway/model/RoleBasedAccessRule.java +++ b/gateway/src/main/java/org/georchestra/gateway/model/RoleBasedAccessRule.java @@ -28,11 +28,12 @@ import lombok.experimental.Accessors; /** - * Models access rules to intercepted Ant-pattern URIs based on roles. + * Defines access rules for intercepted Ant-pattern URIs based on user roles. *

- * Role names are defined by the authenticated user's - * {@link AbstractAuthenticationToken#getAuthorities() authority names} (i.e. - * {@link GrantedAuthority#getAuthority()}) . + * Role names correspond to the authenticated user's + * {@link AbstractAuthenticationToken#getAuthorities() authority names}, which + * are obtained via {@link GrantedAuthority#getAuthority()}. + *

*/ @Data @Generated @@ -40,31 +41,39 @@ public class RoleBasedAccessRule { /** - * List of Ant pattern URI's, excluding the application context, the Gateway - * shall intercept and apply the access rules defined here. E.g. + * List of Ant pattern URIs (excluding the application context) that the gateway + * intercepts to enforce access rules. */ private List interceptUrl = List.of(); /** - * Highest precedence rule, if {@code true}, forbids access to the intercepted - * URLs + * Specifies whether access to the intercepted URLs is explicitly forbidden. + *

+ * If set to {@code true}, access is denied to all users, regardless of role. + *

*/ private boolean forbidden = false; /** - * Whether anonymous (unauthenticated) access is to be granted to the - * intercepted URIs. If {@code true}, no further specification is applied to the - * intercepted urls (i.e. if set, {@link #allowedRoles} are ignored). If - * {@code false} and the {@link #getAllowedRoles() allowed roles} is empty, then - * any authenticated user is granted access to the {@link #getInterceptUrl() - * intercepted URLs}. + * Determines whether anonymous (unauthenticated) access is allowed. + *

+ * If set to {@code true}, no additional role-based access checks are applied to + * the intercepted URLs, meaning all users can access them.
+ * If set to {@code false} and {@link #allowedRoles} is empty, access is granted + * to any authenticated user. + *

*/ private boolean anonymous = false; /** - * Role names that the authenticated user must be part of to be granted access - * to the intercepted URIs. The ROLE_ prefix is optional. For example, the role - * set [ROLE_USER, ROLE_AUDITOR] is equivalent to [USER, AUDITOR] + * Specifies the roles required to access the intercepted URIs. + *

+ * If the list is empty and {@link #anonymous} is {@code false}, any + * authenticated user is granted access.
+ * Role names can be provided with or without the {@code ROLE_} prefix. For + * example, the role set {@code [ROLE_USER, ROLE_AUDITOR]} is equivalent to + * {@code [USER, AUDITOR]}. + *

*/ private List allowedRoles = List.of(); } diff --git a/gateway/src/main/java/org/georchestra/gateway/model/Service.java b/gateway/src/main/java/org/georchestra/gateway/model/Service.java index d86b0304..89360b45 100644 --- a/gateway/src/main/java/org/georchestra/gateway/model/Service.java +++ b/gateway/src/main/java/org/georchestra/gateway/model/Service.java @@ -26,31 +26,47 @@ import lombok.Generated; /** - * Model object used to configure which authenticated user's roles can reach a - * given backend service URIs, and which HTTP request headers to append to the - * proxied requests. - * + * Represents the configuration of a backend service within the geOrchestra + * Gateway. + *

+ * This model defines the target service URL, role-based access rules, and + * security headers to be applied to proxied requests. + *

*/ @Data @Generated public class Service { + /** - * Back end service URL the Gateway will use to proxy incoming requests to, - * based on the {@link #getAccessRules() access rules} - * {@link RoleBasedAccessRule#getInterceptUrl() intercept-URLs} + * The backend service URL to which the gateway will proxy incoming requests. + *

+ * The routing is determined based on the {@link #getAccessRules() access rules} + * and their associated {@link RoleBasedAccessRule#getInterceptUrl() + * intercept-URLs}. + *

*/ private URI target; /** - * Service-specific security headers configuration + * Service-specific security headers configuration. + *

+ * These headers will be appended to requests forwarded to the backend service. + *

*/ private HeaderMappings headers; /** - * List of Ant-pattern based access rules for the given back-end service + * List of Ant-pattern based access rules for controlling access to the backend + * service. */ private List accessRules = List.of(); + /** + * Retrieves the optional security headers configuration for this service. + * + * @return an {@link Optional} containing the {@link HeaderMappings}, or empty + * if not defined + */ public Optional headers() { return Optional.ofNullable(headers); } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/CustomAccessDeniedHandler.java b/gateway/src/main/java/org/georchestra/gateway/security/CustomAccessDeniedHandler.java index 500420c8..1377b9ef 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/CustomAccessDeniedHandler.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/CustomAccessDeniedHandler.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2024 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ package org.georchestra.gateway.security; import org.springframework.http.HttpStatus; @@ -7,10 +25,18 @@ import reactor.core.publisher.Mono; +/** + * Custom implementation of {@link ServerAccessDeniedHandler} to handle access + * denied scenarios. + *

+ * This handler throws an {@link AccessDeniedException} with an HTTP 403 + * (Forbidden) status whenever access is denied. + *

+ */ public class CustomAccessDeniedHandler implements ServerAccessDeniedHandler { @Override public Mono handle(ServerWebExchange serverWebExchange, AccessDeniedException accessDeniedException) { throw new AccessDeniedException(HttpStatus.FORBIDDEN.name()); } -} \ No newline at end of file +} diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ExtendedRedirectServerAuthenticationFailureHandler.java b/gateway/src/main/java/org/georchestra/gateway/security/ExtendedRedirectServerAuthenticationFailureHandler.java index 814f8dda..1ee3076b 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ExtendedRedirectServerAuthenticationFailureHandler.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ExtendedRedirectServerAuthenticationFailureHandler.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2024 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ package org.georchestra.gateway.security; import java.net.URI; @@ -11,21 +29,53 @@ import reactor.core.publisher.Mono; +/** + * Extended version of {@link RedirectServerAuthenticationFailureHandler} to + * provide more granular authentication failure handling. + *

+ * This handler inspects the cause of authentication failure and redirects to + * different locations based on the type of exception encountered. + *

+ *

+ * Specifically, it: + *

    + *
  • Redirects to {@code login?error=invalid_credentials} for bad + * credentials.
  • + *
  • Redirects to {@code login?error=expired_password} for expired + * passwords.
  • + *
  • Defaults to {@code login?error} for other authentication failures.
  • + *
+ *

+ */ public class ExtendedRedirectServerAuthenticationFailureHandler extends RedirectServerAuthenticationFailureHandler { private URI location; - private static String INVALID_CREDENTIALS = "invalid_credentials"; - private static String EXPIRED_PASSWORD = "expired_password"; - private static String EXPIRED_MESSAGE = "Your password has expired"; - private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + private static final String INVALID_CREDENTIALS = "invalid_credentials"; + private static final String EXPIRED_PASSWORD = "expired_password"; + private static final String EXPIRED_MESSAGE = "Your password has expired"; + private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + /** + * Constructs an {@code ExtendedRedirectServerAuthenticationFailureHandler} with + * the default redirection location. + * + * @param location the base URI for authentication failure redirection + */ public ExtendedRedirectServerAuthenticationFailureHandler(String location) { super(location); Assert.notNull(location, "location cannot be null"); this.location = URI.create(location); } + /** + * Handles authentication failures by determining the specific cause and + * redirecting accordingly. + * + * @param webFilterExchange the current web exchange + * @param exception the exception that caused authentication failure + * @return a {@link Mono} signaling completion after the redirect + */ @Override public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, AuthenticationException exception) { this.location = URI.create("login?error"); @@ -37,5 +87,4 @@ public Mono onAuthenticationFailure(WebFilterExchange webFilterExchange, A } return this.redirectStrategy.sendRedirect(webFilterExchange.getExchange(), this.location); } - } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/GatewaySecurityConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/GatewaySecurityConfiguration.java index e08a6bad..471f4895 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/GatewaySecurityConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/GatewaySecurityConfiguration.java @@ -41,16 +41,18 @@ import lombok.extern.slf4j.Slf4j; /** - * {@link Configuration} to initialize the Gateway's - * {@link SecurityWebFilterChain} during application start up, such as - * establishing path based access rules, configuring authentication providers, - * etc. + * Configuration for the security settings in geOrchestra Gateway. *

- * Note this configuration does very little by itself. Instead, it relies on - * available beans implementing the {@link ServerHttpSecurityCustomizer} - * extension point to tweak the {@link ServerHttpSecurity} as appropriate in a - * decoupled way. - * + * This configuration initializes the {@link SecurityWebFilterChain}, handling + * authentication, authorization, and security policies. + *

+ * + *

+ * Instead of defining all security settings directly, this configuration relies + * on {@link ServerHttpSecurityCustomizer} implementations to allow decoupled + * and extensible security customization. + *

+ * * @see ServerHttpSecurityCustomizer */ @Configuration(proxyBeanMethods = false) @@ -65,24 +67,26 @@ public class GatewaySecurityConfiguration { private @Value("${georchestra.gateway.logoutUrl:/?logout}") String georchestraLogoutUrl; /** - * Relies on available {@link ServerHttpSecurityCustomizer} extensions to - * configure the different aspects of the {@link ServerHttpSecurity} used to - * {@link ServerHttpSecurity#build build} the {@link SecurityWebFilterChain}. - *

- * Disables appending default response headers as far as the regular - * Spring-Security is concerned. This way, we let Spring Cloud Gateway control - * their behavior. Otherwise the config property - * {@literal spring.cloud.gateway.filter.secure-headers.disable: x-frame-options} - * has no effect. + * Configures security settings for the gateway using available customizers. *

- * Note also {@literal spring.cloud.gateway.default-filters} must contain the - * {@literal SecureHeaders} filter. + * This method: + *

    + *
  • Disables CSRF protection (expected to be handled by proxied web + * apps).
  • + *
  • Disables default security response headers to allow Spring Cloud Gateway + * to manage them.
  • + *
  • Applies a custom access denied handler.
  • + *
  • Sets up form-based login handling.
  • + *
  • Applies all available {@link ServerHttpSecurityCustomizer} extensions in + * order.
  • + *
  • Configures logout handling, using an OIDC logout handler if + * available.
  • + *
+ *

+ * *

- * Finally, note - * {@literal spring.cloud.gateway.default-filters: x-frame-options} won't - * prevent downstream services so provide their own header. - *

- * The following are the default headers suppressed here: + * The following response headers are disabled by default: + *

* *
      * 
@@ -95,6 +99,12 @@ public class GatewaySecurityConfiguration {
      * X-XSS-Protection: 1; mode=block
      * 
      * 
+ * + * @param http the {@link ServerHttpSecurity} instance + * @param customizers the list of available {@link ServerHttpSecurityCustomizer} + * implementations + * @return the configured {@link SecurityWebFilterChain} + * @throws Exception if an error occurs during configuration */ @Bean SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, @@ -102,14 +112,8 @@ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, log.info("Initializing security filter chain..."); - // disable CSRF protection, considering it will be managed - // by proxified webapps, not the gateway. http.csrf().disable(); - - // disable default response headers. See comment in the method's javadoc http.headers().disable(); - - // custom handling for forbidden error http.exceptionHandling().accessDeniedHandler(new CustomAccessDeniedHandler()); http.formLogin() @@ -126,31 +130,58 @@ SecurityWebFilterChain securityWebFilterChain(ServerHttpSecurity http, RedirectServerLogoutSuccessHandler defaultRedirect = new RedirectServerLogoutSuccessHandler(); defaultRedirect.setLogoutSuccessUrl(URI.create(georchestraLogoutUrl)); - LogoutSpec logoutUrl = http.formLogin().loginPage("/login").and().logout() + LogoutSpec logoutSpec = http.formLogin().loginPage("/login").and().logout() .requiresLogout(ServerWebExchangeMatchers.pathMatchers(HttpMethod.GET, "/logout")) .logoutSuccessHandler(oidcLogoutSuccessHandler != null ? oidcLogoutSuccessHandler : defaultRedirect); - return logoutUrl.and().build(); + return logoutSpec.and().build(); } + /** + * Sorts and returns the list of custom security configurations. + * + * @param customizers the list of security customizers + * @return a sorted stream of {@link ServerHttpSecurityCustomizer} instances + */ private Stream sortedCustomizers(List customizers) { return customizers.stream().sorted((c1, c2) -> Integer.compare(c1.getOrder(), c2.getOrder())); } + /** + * Creates a {@link GeorchestraUserMapper} to resolve user identities using the + * configured resolvers and customizers. + * + * @param resolvers the list of user resolvers + * @param customizers the list of user customizers + * @return an instance of {@link GeorchestraUserMapper} + */ @Bean GeorchestraUserMapper georchestraUserResolver(List resolvers, List customizers) { return new GeorchestraUserMapper(resolvers, customizers); } + /** + * Creates a global filter that resolves authenticated geOrchestra users in the + * request lifecycle. + * + * @param resolver the {@link GeorchestraUserMapper} used to resolve users + * @return an instance of {@link ResolveGeorchestraUserGlobalFilter} + */ @Bean ResolveGeorchestraUserGlobalFilter resolveGeorchestraUserGlobalFilter(GeorchestraUserMapper resolver) { return new ResolveGeorchestraUserGlobalFilter(resolver); } /** - * Extension to make {@link GeorchestraUserMapper} append user roles based on - * {@link GatewayConfigProperties#getRolesMappings()} + * Registers a custom user role mapping extension. + *

+ * This extension updates user roles based on the configured mappings in + * {@link GatewayConfigProperties#getRolesMappings()}. + *

+ * + * @param config the gateway configuration properties + * @return an instance of {@link RolesMappingsUserCustomizer} */ @Bean RolesMappingsUserCustomizer rolesMappingsUserCustomizer(GatewayConfigProperties config) { @@ -158,5 +189,4 @@ RolesMappingsUserCustomizer rolesMappingsUserCustomizer(GatewayConfigProperties log.info("Creating {}", RolesMappingsUserCustomizer.class.getSimpleName()); return new RolesMappingsUserCustomizer(rolesMappings); } - } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraGatewaySecurityConfigProperties.java b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraGatewaySecurityConfigProperties.java index c2fcaaf9..ba57899c 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraGatewaySecurityConfigProperties.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraGatewaySecurityConfigProperties.java @@ -40,13 +40,15 @@ import lombok.experimental.Accessors; /** - * Config properties, usually loaded from georchestra datadir's - * {@literal default.properties}. + * Configuration properties for geOrchestra Gateway security settings, typically + * loaded from the geOrchestra data directory's {@literal default.properties} + * file. *

- * e.g.: + * Example configuration: + *

* *
- *{@code 
+ * {@code 
  * ldapHost=localhost
  * ldapPort=389
  * ldapScheme=ldap
@@ -64,130 +66,166 @@
 @ConfigurationProperties(prefix = "georchestra.gateway.security")
 public class GeorchestraGatewaySecurityConfigProperties implements Validator {
 
+    /**
+     * Flag indicating whether non-existing users should be created in LDAP
+     * automatically.
+     */
     private boolean createNonExistingUsersInLDAP = true;
 
+    /**
+     * Default organization assigned to users when no specific organization is set.
+     */
     private String defaultOrganization = "";
 
+    /**
+     * LDAP server configurations mapped by their respective names.
+     */
     @Valid
     private Map ldap = Map.of();
 
+    /**
+     * Represents a configured LDAP server.
+     */
     @Generated
     public static @Data class Server {
 
+        /**
+         * Indicates whether this LDAP configuration is enabled.
+         */
         boolean enabled;
 
         /**
-         * Whether the LDAP authentication source shall use georchestra-specific
+         * Whether the LDAP authentication source shall use geOrchestra-specific
          * extensions. For example, when using the default OpenLDAP database with
-         * additional user identity information
+         * additional user identity information.
          */
         boolean extended;
 
+        /**
+         * URL of the LDAP server.
+         */
         private String url;
 
         /**
-         * Flag indicating the LDAP authentication end point is an Active Directory
-         * service
+         * Flag indicating if the LDAP authentication endpoint is an Active Directory
+         * service.
          */
         private boolean activeDirectory;
 
         /**
-         * Base DN of the LDAP directory Base Distinguished Name of the LDAP directory.
-         * Also named root or suffix, see
-         * http://www.zytrax.com/books/ldap/apd/index.html#base
-         * 

- * For example, georchestra's default baseDn is dc=georchestra,dc=org + * Base Distinguished Name (DN) of the LDAP directory. This represents the root + * suffix. Example: {@code dc=georchestra,dc=org}. */ private String baseDn; /** - * How to extract user information. Only searchFilter is used if activeDirectory - * is true + * Configuration for extracting user information. When {@code activeDirectory} + * is {@code true}, only {@code searchFilter} is used. */ private Users users; /** - * How to extract role information, un-used for Active Directory + * Configuration for extracting role information. This setting is unused for + * Active Directory. */ private Roles roles; /** - * How to extract Organization information, only used for OpenLDAP if extended = - * true + * Configuration for extracting organization information. Used only for OpenLDAP + * when {@code extended} is {@code true}. */ private Organizations orgs; /** - * The user distinguished name (principal) to use for getting authenticated - * contexts (optional). + * Distinguished Name (DN) of the administrator user used for LDAP + * authentication operations. */ private String adminDn; /** - * The password (credentials) to use for getting authenticated contexts - * (optional). + * Password for the administrator user used for LDAP authentication operations. */ private String adminPassword; } + /** + * Configuration for user-related LDAP attributes. + */ @Generated public static @Data @Accessors(chain = true) class Users { /** - * Users RDN Relative distinguished name of the "users" LDAP organization unit. - * E.g. if the complete name (or DN) is ou=users,dc=georchestra,dc=org, the RDN - * is ou=users. + * Relative Distinguished Name (RDN) of the organizational unit containing + * users. Example: If the full DN is {@code ou=users,dc=georchestra,dc=org}, the + * RDN is {@code ou=users}. */ private String rdn; /** - * Users search filter, e.g. (uid={0}) for OpenLDAP, and - * (&(objectClass=user)(userPrincipalName={0})) for ActiveDirectory + * LDAP search filter to find users. Example: {@code (uid={0})} for OpenLDAP or + * {@code (&(objectClass=user)(userPrincipalName={0}))} for Active Directory. */ private String searchFilter; /** - * Specifies the attributes that will be returned as part of the search. - *

- * null indicates that all attributes will be returned. An empty array indicates - * no attributes are returned. + * Specifies the LDAP attributes to be returned in search results. {@code null} + * indicates all attributes will be returned. An empty array means no attributes + * will be returned. */ private @Setter String[] returningAttributes; } + /** + * Configuration for role-related LDAP attributes. + */ @Generated public static @Data @Accessors(chain = true) class Roles { /** - * Roles RDN Relative distinguished name of the "roles" LDAP organization unit. - * E.g. if the complete name (or DN) is ou=roles,dc=georchestra,dc=org, the RDN - * is ou=roles. + * Relative Distinguished Name (RDN) of the organizational unit containing + * roles. Example: If the full DN is {@code ou=roles,dc=georchestra,dc=org}, the + * RDN is {@code ou=roles}. */ private String rdn; /** - * Roles search filter. e.g. (member={0}) + * LDAP search filter used to determine role membership. Example: + * {@code (member={0})}. */ private String searchFilter; } + /** + * Configuration for organization-related LDAP attributes. + */ @Generated public static @Data @Accessors(chain = true) class Organizations { /** - * Organizations search base. Default: ou=orgs + * Relative Distinguished Name (RDN) of the organizational unit containing + * organizations. Default value: {@code ou=orgs}. */ private String rdn = "ou=orgs"; /** - * Pending organizations search base. Default: ou=pendingorgs + * Relative Distinguished Name (RDN) of the organizational unit containing + * pending organizations. Default value: {@code ou=pendingorgs}. */ private String pendingRdn = "ou=pendingorgs"; } + /** + * {@inheritDoc} + */ public @Override boolean supports(Class clazz) { return GeorchestraGatewaySecurityConfigProperties.class.equals(clazz); } + /** + * Validates the LDAP configuration properties. + * + * @param target the instance to validate + * @param errors the validation errors + */ @Override public void validate(Object target, Errors errors) { GeorchestraGatewaySecurityConfigProperties config = (GeorchestraGatewaySecurityConfigProperties) target; @@ -199,26 +237,35 @@ public void validate(Object target, Errors errors) { ldapConfig.forEach((name, serverConfig) -> validations.validate(name, serverConfig, errors)); } + /** + * Retrieves the list of enabled simple (non-extended) LDAP configurations. + * + * @return a list of basic {@link LdapServerConfig} instances. + */ public List simpleEnabled() { LdapConfigBuilder builder = new LdapConfigBuilder(); - return entries()// - .filter(e -> e.getValue().isEnabled())// - .filter(e -> !e.getValue().isExtended())// + return entries().filter(e -> e.getValue().isEnabled()).filter(e -> !e.getValue().isExtended()) .map(e -> builder.asBasicLdapConfig(e.getKey(), e.getValue())).toList(); } + /** + * Retrieves the list of enabled extended LDAP configurations. + * + * @return a list of {@link ExtendedLdapConfig} instances. + */ public List extendedEnabled() { LdapConfigBuilder builder = new LdapConfigBuilder(); - return entries()// - .filter(e -> e.getValue().isEnabled())// - .filter(e -> !e.getValue().isActiveDirectory())// - .filter(e -> e.getValue().isExtended())// - .map(e -> builder.asExtendedLdapConfig(e.getKey(), e.getValue()))// + return entries().filter(e -> e.getValue().isEnabled()).filter(e -> !e.getValue().isActiveDirectory()) + .filter(e -> e.getValue().isExtended()).map(e -> builder.asExtendedLdapConfig(e.getKey(), e.getValue())) .toList(); } + /** + * Retrieves a stream of LDAP configuration entries. + * + * @return a stream of LDAP server entries. + */ private Stream> entries() { return ldap == null ? Stream.empty() : ldap.entrySet().stream(); } - } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserCustomizerExtension.java b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserCustomizerExtension.java index 55ccb052..a0cda39c 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserCustomizerExtension.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserCustomizerExtension.java @@ -26,15 +26,29 @@ import org.springframework.security.core.Authentication; /** - * Extension point to customize the state of a {@link GeorchestraUser} once it - * was obtained from an authentication provider by means of a - * {@link GeorchestraUserMapperExtension}. - * + * Extension point to customize the {@link GeorchestraUser} after it has been + * resolved from an authentication provider. + *

+ * This interface allows modifying the {@link GeorchestraUser} instance based on + * authentication details, such as applying additional role mappings, setting + * organization attributes, or enriching the user with external metadata. + *

+ *

+ * Implementations are executed in the order defined by {@link #getOrder()}. + *

+ * * @see GeorchestraUserMapper + * @see GeorchestraUserMapperExtension */ public interface GeorchestraUserCustomizerExtension extends Ordered, BiFunction { + /** + * Defines the execution order of this extension when multiple customizers are + * available. + * + * @return the order in which this customizer is applied, defaults to {@code 0}. + */ default int getOrder() { return 0; } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapper.java index 0c266990..8adc3f19 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapper.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapper.java @@ -21,71 +21,105 @@ import java.util.List; import java.util.Optional; -import org.georchestra.gateway.model.GeorchestraUsers; import org.georchestra.gateway.security.exceptions.DuplicatedEmailFoundException; import org.georchestra.security.model.GeorchestraUser; -import org.springframework.core.Ordered; import org.springframework.security.core.Authentication; import lombok.NonNull; import lombok.RequiredArgsConstructor; /** - * Aids {@link ResolveGeorchestraUserGlobalFilter} in resolving the - * {@link GeorchestraUser} from the current request's {@link Authentication} - * token. + * Resolves a {@link GeorchestraUser} from an {@link Authentication} token by + * delegating to available {@link GeorchestraUserMapperExtension} + * implementations. *

- * Relies on the provided {@link GeorchestraUserMapperExtension}s to map an - * {@link Authentication} to a {@link GeorchestraUsers}, and on - * {@link GeorchestraUserCustomizerExtension} to apply additional user - * customizations once resolved from {@link Authentication} to - * {@link GeorchestraUser}. + * This class acts as an abstraction layer that allows multiple authentication + * strategies to provide user resolution mechanisms, such as LDAP, OAuth2, or + * custom authentication providers. + *

*

- * {@literal GeorchestraUserMapperExtension} beans specialize in mapping auth - * tokens for specific authentication sources (e.g. LDAP, OAuth2, OAuth2+OpenID, - * etc). + * Once a user is successfully resolved, any registered + * {@link GeorchestraUserCustomizerExtension} implementations are applied in + * order to modify or enrich the user attributes. + *

*

- * {@literal GeorchestraUserCustomizerExtension} beans specialize in applying - * any additional customization to the {@link GeorchestraUser} object after it - * has been extracted from the {@link Authentication} created by the actual - * authentication provider. + * This component is primarily used by + * {@link ResolveGeorchestraUserGlobalFilter} to extract user details from + * authentication tokens in the request lifecycle. + *

* * @see GeorchestraUserMapperExtension * @see GeorchestraUserCustomizerExtension + * @see ResolveGeorchestraUserGlobalFilter */ @RequiredArgsConstructor public class GeorchestraUserMapper { /** - * {@link Ordered ordered} list of user mapper extensions. + * Ordered list of user mapper extensions responsible for resolving a + * {@link GeorchestraUser} from an {@link Authentication} token. */ private final @NonNull List resolvers; + /** + * Ordered list of user customizer extensions that apply modifications to a + * resolved {@link GeorchestraUser}. + */ private final @NonNull List customizers; + /** + * Default constructor for use when no resolvers or customizers are provided. + */ GeorchestraUserMapper() { this(List.of(), List.of()); } + /** + * Constructor for initializing only with user resolvers. + * + * @param resolvers the list of {@link GeorchestraUserMapperExtension} instances + */ GeorchestraUserMapper(List resolvers) { this(resolvers, List.of()); } /** - * @return the first non-empty user from - * {@link GeorchestraUserMapperExtension#resolve asking} the extension - * point implementations to resolve the user from the token, or - * {@link Optional#empty()} if no extension point implementation can - * handle the auth token. + * Attempts to resolve a {@link GeorchestraUser} from the provided + * authentication token. + *

+ * Each {@link GeorchestraUserMapperExtension} is queried in order until one + * successfully resolves a user. If no extension handles the authentication + * token, an empty result is returned. + *

+ *

+ * If a user is resolved, it is then processed through all registered + * {@link GeorchestraUserCustomizerExtension} instances in order. + *

+ * + * @param authToken the authentication token to resolve + * @return an optional {@link GeorchestraUser} if resolution is successful + * @throws DuplicatedEmailFoundException if multiple users with the same email + * are found */ public Optional resolve(@NonNull Authentication authToken) throws DuplicatedEmailFoundException { - return resolvers.stream()// - .map(resolver -> resolver.resolve(authToken))// - .filter(Optional::isPresent)// - .map(Optional::orElseThrow)// - .map(mapped -> customize(authToken, mapped)).findFirst(); + return resolvers.stream().map(resolver -> resolver.resolve(authToken)).filter(Optional::isPresent) + .map(Optional::orElseThrow).map(mapped -> customize(authToken, mapped)).findFirst(); } + /** + * Applies registered {@link GeorchestraUserCustomizerExtension} instances to + * the resolved user. + *

+ * This allows for modifications such as role mappings, attribute enrichment, or + * other custom transformations based on the authentication context. + *

+ * + * @param authToken the authentication token associated with the user + * @param mapped the resolved {@link GeorchestraUser} instance + * @return the customized {@link GeorchestraUser} after all modifications are + * applied + * @throws DuplicatedEmailFoundException if an issue occurs during customization + */ private GeorchestraUser customize(@NonNull Authentication authToken, GeorchestraUser mapped) throws DuplicatedEmailFoundException { GeorchestraUser customized = mapped; @@ -94,4 +128,4 @@ private GeorchestraUser customize(@NonNull Authentication authToken, Georchestra } return customized; } -} \ No newline at end of file +} diff --git a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapperExtension.java b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapperExtension.java index 17f074b9..4f507ce6 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapperExtension.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/GeorchestraUserMapperExtension.java @@ -16,7 +16,6 @@ * You should have received a copy of the GNU General Public License along with * geOrchestra. If not, see . */ - package org.georchestra.gateway.security; import java.util.Optional; @@ -26,24 +25,55 @@ import org.springframework.security.core.Authentication; /** - * Extension point to decouple the authentication origin from the logic to - * convey geOrchestra-specific HTTP security request headers to back-end - * services. + * Defines an extension point for mapping authentication tokens to + * {@link GeorchestraUser} instances. + *

+ * This interface allows different authentication mechanisms (e.g., LDAP, + * OAuth2, OpenID Connect) to provide their own strategy for extracting user + * details from an {@link Authentication} token. + *

+ *

+ * Implementations of this interface are queried by + * {@link GeorchestraUserMapper} to determine whether they can handle the given + * authentication token. If a suitable implementation is found, it returns a + * non-empty {@link GeorchestraUser}. + *

+ * *

- * Beans of this type will be asked by {@link GeorchestraUserMapper} to obtain a - * {@link GeorchestraUser} from the current request authentication token. An - * instance that knows how to perform such mapping based on the kind of - * authentication represented by the token shall return a non-empty user. + * Beans of this type are {@link Ordered}, meaning multiple resolvers can be + * defined with explicit ordering to prioritize certain authentication sources + * over others. + *

+ * + * @see GeorchestraUserMapper */ public interface GeorchestraUserMapperExtension extends Ordered { /** - * @return the mapped {@link GeorchestraUser} based on the provided auth token, - * or {@link Optional#empty()} if this instance can't perform such - * mapping. + * Attempts to map an {@link Authentication} token to a {@link GeorchestraUser}. + *

+ * If this implementation can extract user details from the provided + * authentication token, it should return a populated {@link GeorchestraUser}. + * Otherwise, it should return {@link Optional#empty()} to allow other resolvers + * to handle the token. + *

+ * + * @param authToken the authentication token representing the user's credentials + * @return an optional {@link GeorchestraUser} if this resolver can handle the + * authentication token */ Optional resolve(Authentication authToken); + /** + * Defines the order in which this resolver should be executed relative to other + * {@link GeorchestraUserMapperExtension} implementations. + *

+ * A lower value indicates higher priority. + *

+ * + * @return {@code 0} as the default order. Implementations can override this if + * needed. + */ default int getOrder() { return 0; } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilter.java b/gateway/src/main/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilter.java index 1d27f56e..8537d99c 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilter.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ResolveGeorchestraUserGlobalFilter.java @@ -21,7 +21,6 @@ import java.net.URI; import org.georchestra.gateway.model.GeorchestraOrganizations; -import org.georchestra.gateway.model.GeorchestraTargetConfig; import org.georchestra.gateway.model.GeorchestraUsers; import org.georchestra.gateway.security.exceptions.DuplicatedEmailFoundException; import org.georchestra.gateway.security.ldap.extended.ExtendedGeorchestraUser; @@ -46,13 +45,27 @@ /** * A {@link GlobalFilter} that resolves the {@link GeorchestraUser} from the * request's {@link Authentication} so it can be {@link GeorchestraUsers#resolve - * retrieved} down the road during a server web exchange filter chain execution. + * retrieved} during subsequent filter chain execution. *

- * The resolved per-request {@link GeorchestraUser user} object can then, for - * example, be used to append the necessary {@literal sec-*} headers that relate - * to user information to proxied http requests. + * This filter ensures that each request has access to the authenticated user, + * which can be used to populate security-related headers (e.g., + * {@literal sec-*} headers) when forwarding requests to backend services. + *

* + *

+ * If the resolved {@link GeorchestraUser} is an instance of + * {@link ExtendedGeorchestraUser}, this filter also extracts the associated + * {@link Organization} and makes it available for downstream processing. + *

+ * + *

+ * If a {@link DuplicatedEmailFoundException} occurs, the user is redirected to + * the login page with an error flag, and the session is invalidated. + *

+ * * @see GeorchestraUserMapper + * @see GeorchestraUsers + * @see GeorchestraOrganizations */ @RequiredArgsConstructor @Slf4j(topic = "org.georchestra.gateway.security") @@ -62,49 +75,81 @@ public class ResolveGeorchestraUserGlobalFilter implements GlobalFilter, Ordered private final @NonNull GeorchestraUserMapper resolver; - private ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); + private final ServerRedirectStrategy redirectStrategy = new DefaultServerRedirectStrategy(); - private static String DUPLICATE_ACCOUNT = "duplicate_account"; + private static final String DUPLICATE_ACCOUNT_ERROR = "duplicate_account"; /** - * @return a lower precedence than {@link RouteToRequestUrlFilter}'s, in order - * to make sure the matched {@link Route} has been set as a - * {@link ServerWebExchange#getAttributes attribute} when - * {@link #filter} is called. + * Defines the order in which this filter executes relative to other + * {@link GlobalFilter} implementations. + *

+ * It runs right after {@link RouteToRequestUrlFilter} to ensure that the + * matched {@link Route} has been determined before resolving the user. + *

+ * + * @return filter execution order */ - public @Override int getOrder() { + @Override + public int getOrder() { return ORDER; } /** - * Resolves the matched {@link Route} and its corresponding - * {@link GeorchestraTargetConfig}, if possible, and proceeds with the filter - * chain. + * Resolves the authenticated {@link GeorchestraUser} from the request context + * and stores it for downstream processing. + *

+ * If an {@link ExtendedGeorchestraUser} is found, the associated + * {@link Organization} is also extracted and stored. + *

+ *

+ * If a {@link DuplicatedEmailFoundException} is encountered, the user is + * redirected to the login page with an error message, and the session is + * invalidated. + *

+ * + * @param exchange the current server exchange + * @param chain the filter chain + * @return a {@link Mono} that completes when processing is finished + */ + @Override + public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { + return exchange.getPrincipal() + .doOnNext(principal -> log.debug("Resolving user from {}", principal.getClass().getName())) + .filter(Authentication.class::isInstance).map(Authentication.class::cast).map(resolver::resolve) + .map(user -> storeUserAndOrganization(exchange, user.orElse(null))).defaultIfEmpty(exchange) + .flatMap(chain::filter) + .onErrorResume(DuplicatedEmailFoundException.class, error -> handleDuplicateEmailError(exchange)); + } + + /** + * Stores the resolved {@link GeorchestraUser} and its associated + * {@link Organization} (if applicable) in the exchange attributes. + * + * @param exchange the current server exchange + * @param user the resolved user, or {@code null} if none found + * @return the updated server exchange */ - public @Override Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { - return exchange.getPrincipal()// - .doOnNext(p -> log.debug("resolving user from {}", p.getClass().getName()))// - .filter(Authentication.class::isInstance)// - .map(Authentication.class::cast)// - .map(resolver::resolve)// - .map(user -> { - GeorchestraUser usr = user.orElse(null); - GeorchestraUsers.store(exchange, usr); - if (usr != null && usr instanceof ExtendedGeorchestraUser) { - ExtendedGeorchestraUser eu = (ExtendedGeorchestraUser) usr; - Organization org = eu.getOrg(); - if (org != null) { - GeorchestraOrganizations.store(exchange, org); - } - } - return exchange; - })// - .defaultIfEmpty(exchange)// - .flatMap(chain::filter)// - .onErrorResume(DuplicatedEmailFoundException.class, - exp -> this.redirectStrategy - .sendRedirect(exchange, URI.create("/login?error=" + DUPLICATE_ACCOUNT)) - .then(exchange.getSession().flatMap(WebSession::invalidate))); + private ServerWebExchange storeUserAndOrganization(ServerWebExchange exchange, GeorchestraUser user) { + GeorchestraUsers.store(exchange, user); + + if (user instanceof ExtendedGeorchestraUser extendedUser) { + Organization org = extendedUser.getOrg(); + if (org != null) { + GeorchestraOrganizations.store(exchange, org); + } + } + return exchange; } -} \ No newline at end of file + /** + * Handles a {@link DuplicatedEmailFoundException} by redirecting the user to + * the login page with an error message and invalidating the session. + * + * @param exchange the current server exchange + * @return a {@link Mono} signaling the redirect operation + */ + private Mono handleDuplicateEmailError(ServerWebExchange exchange) { + return redirectStrategy.sendRedirect(exchange, URI.create("/login?error=" + DUPLICATE_ACCOUNT_ERROR)) + .then(exchange.getSession().flatMap(WebSession::invalidate)); + } +} diff --git a/gateway/src/main/java/org/georchestra/gateway/security/RolesMappingsUserCustomizer.java b/gateway/src/main/java/org/georchestra/gateway/security/RolesMappingsUserCustomizer.java index 9be15476..84f08e29 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/RolesMappingsUserCustomizer.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/RolesMappingsUserCustomizer.java @@ -40,22 +40,70 @@ import lombok.extern.slf4j.Slf4j; /** - * Authenticated user customizer extension to expand the set of role names - * assigned to a user by the actual authentication provider + * A {@link GeorchestraUserCustomizerExtension} that expands the set of role + * names assigned to a user by the authentication provider based on role mapping + * rules. + *

+ * This implementation allows assigning additional roles dynamically by defining + * mapping patterns in the gateway configuration. + *

+ * + *

+ * Role mappings are stored as a set of regular expression patterns. When a + * user's authenticated role matches a pattern, the corresponding mapped roles + * are added to the user's role set. + *

+ * + *

+ * Example role mapping configuration: + *

+ * + *
+ * 
+ * rolesMappings:
+ *   "ADMIN*": ["SUPERUSER"]
+ *   "USER": ["READ_ONLY"]
+ * 
+ * 
+ * + *

+ * In the above example, any role starting with {@code ADMIN} will be assigned + * the {@code SUPERUSER} role, and users with the {@code USER} role will + * automatically receive the {@code READ_ONLY} role. + *

+ * + *

+ * This component also employs caching for performance optimization, avoiding + * redundant computations when resolving additional roles for a given + * authentication. + *

+ * + * @see GeorchestraUserMapper */ @Slf4j public class RolesMappingsUserCustomizer implements GeorchestraUserCustomizerExtension { + /** + * Represents a role mapping rule, associating a regex-based pattern with a set + * of additional roles. + */ @RequiredArgsConstructor private static class Matcher { private final @NonNull Pattern pattern; private final @NonNull @Getter List extraRoles; + /** + * Checks if the given role matches the pattern. + * + * @param role the role name to check + * @return {@code true} if the role matches the pattern, otherwise {@code false} + */ public boolean matches(String role) { return pattern.matcher(role).matches(); } - public @Override String toString() { + @Override + public String toString() { return String.format("%s -> %s", pattern.pattern(), extraRoles); } } @@ -65,48 +113,92 @@ public boolean matches(String role) { private final Cache> byRoleNameCache = CacheBuilder.newBuilder().maximumSize(1_000).build(); + /** + * Constructs an instance of {@link RolesMappingsUserCustomizer} with the + * provided role mappings. + * + * @param rolesMappings a map where keys represent role name patterns and values + * are lists of additional roles to be assigned when the + * pattern matches + */ public RolesMappingsUserCustomizer(@NonNull Map> rolesMappings) { - this.rolesMappings = keysToRegularExpressions(rolesMappings); + this.rolesMappings = convertKeysToPatterns(rolesMappings); } - private @NonNull List keysToRegularExpressions(Map> mappings) { - return mappings.entrySet()// - .stream()// - .map(e -> new Matcher(toPattern(e.getKey()), e.getValue()))// - .peek(m -> log.info("Loaded role mapping {}", m))// - .toList(); + /** + * Converts role mapping keys into regex-based {@link Matcher} objects. + * + * @param mappings a map where each key is a role name pattern, and the + * corresponding value is a list of additional roles + * @return a list of compiled role matchers + */ + private @NonNull List convertKeysToPatterns(Map> mappings) { + return mappings.entrySet().stream().map(entry -> new Matcher(compilePattern(entry.getKey()), entry.getValue())) + .peek(matcher -> log.info("Loaded role mapping {}", matcher)).toList(); } - static Pattern toPattern(String role) { - String regex = role.replace(".", "(\\.)").replace("*", "(.*)"); + /** + * Converts a role mapping key into a regex pattern. + *

+ * Supports wildcard-based matching where: + *

+ *
    + *
  • {@code *} is converted to {@code .*} (match any characters).
  • + *
  • {@code .} is escaped to match a literal period.
  • + *
+ * + * @param role the role name pattern + * @return the compiled {@link Pattern} + */ + static Pattern compilePattern(String role) { + String regex = role.replace(".", "\\.").replace("*", ".*"); return Pattern.compile(regex); } + /** + * Applies additional role mappings to the authenticated user. + *

+ * This method scans the user's current roles, determines any additional roles + * based on predefined mappings, and updates the user object accordingly. + *

+ * + * @param authToken the original authentication token + * @param mappedUser the user retrieved from authentication + * @return the updated user with any additional roles assigned + */ @Override - public GeorchestraUser apply(Authentication origAuthToken, GeorchestraUser mappedUser) { - + public GeorchestraUser apply(Authentication authToken, GeorchestraUser mappedUser) { Set additionalRoles = computeAdditionalRoles(mappedUser.getRoles()); + if (!additionalRoles.isEmpty()) { additionalRoles.addAll(mappedUser.getRoles()); - mappedUser.setRoles(new ArrayList<>(additionalRoles));// mutable + mappedUser.setRoles(new ArrayList<>(additionalRoles)); // Ensure mutability } + return mappedUser; } /** - * @param authenticatedRoles the role names extracted from the authentication - * provider - * @return the additional role names for the user + * Computes additional roles for the user based on their existing roles. + * + * @param authenticatedRoles the roles assigned by the authentication provider + * @return a set of additional roles derived from mapping rules */ private Set computeAdditionalRoles(List authenticatedRoles) { final ConcurrentMap> cache = byRoleNameCache.asMap(); - return authenticatedRoles.stream().map(role -> cache.computeIfAbsent(role, this::computeAdditionalRoles)) + return authenticatedRoles.stream().map(role -> cache.computeIfAbsent(role, this::resolveAdditionalRoles)) .flatMap(List::stream).collect(Collectors.toSet()); } - private List computeAdditionalRoles(@NonNull String authenticatedRole) { - - List roles = rolesMappings.stream().filter(m -> m.matches(authenticatedRole)) + /** + * Resolves additional roles for a given authenticated role by evaluating the + * configured role mappings. + * + * @param authenticatedRole the role assigned by the authentication provider + * @return a list of additional roles assigned based on mappings + */ + private List resolveAdditionalRoles(@NonNull String authenticatedRole) { + List roles = rolesMappings.stream().filter(matcher -> matcher.matches(authenticatedRole)) .map(Matcher::getExtraRoles).flatMap(List::stream).toList(); log.info("Computed additional roles for {}: {}", authenticatedRole, roles); diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ServerHttpSecurityCustomizer.java b/gateway/src/main/java/org/georchestra/gateway/security/ServerHttpSecurityCustomizer.java index 9ef6e6eb..5e257fde 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ServerHttpSecurityCustomizer.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ServerHttpSecurityCustomizer.java @@ -23,31 +23,40 @@ import org.springframework.security.config.web.server.ServerHttpSecurity; /** - * Extension point to aid {@link GatewaySecurityConfiguration} in initializing + * Extension point to assist {@link GatewaySecurityConfiguration} in configuring * the application security filter chain. *

- * Spring beans of this type implement {@link Ordered}, and will be called in - * sequence adhering to each bean's defined order. + * Implementations of this interface act as modular security configuration + * components that can modify the {@link ServerHttpSecurity} instance during + * application startup. These beans implement {@link Ordered}, ensuring they are + * applied in a predictable sequence based on their defined order. + *

*

- * This interface extends {@link Customizer Customizer}. The - * {@link Customizer#customize customize(ServerHttpSecurity)} shall modify the - * provided server HTTP security configuration bean in whatever way needed. + * This interface extends {@link Customizer} with {@code ServerHttpSecurity}. + * Implementations of the {@link Customizer#customize} method modify the + * provided {@link ServerHttpSecurity} configuration bean as required. + *

*/ public interface ServerHttpSecurityCustomizer extends Customizer, Ordered { /** - * @return user friendly extension name for logging purposes + * Returns a human-readable extension name for logging purposes. + * + * @return the fully qualified class name of the implementing class */ default String getName() { return getClass().getCanonicalName(); } /** - * {@inheritDoc} - * - * @return {@code 0} as default order, implementations should override as needed - * in case they need to apply their customizations to - * {@link ServerHttpSecurity} in a specific order. + * Returns the execution order of this customizer. + *

+ * By default, it returns {@code 0}. Implementations should override this method + * if they need to apply their customizations in a specific order within the + * security configuration process. + *

+ * + * @return the order in which this customizer should be applied * @see Ordered#HIGHEST_PRECEDENCE * @see Ordered#LOWEST_PRECEDENCE */ diff --git a/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesConfiguration.java index b724dd04..4d2418a0 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesConfiguration.java @@ -24,10 +24,36 @@ import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; +/** + * Configures geOrchestra-specific access rules based on role-based security + * policies. + *

+ * This configuration registers the {@link AccessRulesCustomizer}, which applies + * role-based access rules to incoming requests based on the settings defined in + * {@link GatewayConfigProperties}. + *

+ * + *

+ * The rules are configured globally and can be overridden on a per-service + * basis via {@code georchestra.gateway.services.[service].access-rules}. + *

+ * + * @see AccessRulesCustomizer + * @see GatewayConfigProperties#getGlobalAccessRules() + * @see GatewayConfigProperties#getServices() + */ @Configuration @EnableConfigurationProperties(GatewayConfigProperties.class) public class AccessRulesConfiguration { + /** + * Registers the {@link AccessRulesCustomizer} bean to enforce role-based access + * rules. + * + * @param config the gateway configuration properties + * @param userMapper the user identity resolver for extracting user roles + * @return an instance of {@link AccessRulesCustomizer} + */ @Bean AccessRulesCustomizer georchestraAccessRulesCustomizer(GatewayConfigProperties config, GeorchestraUserMapper userMapper) { diff --git a/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizer.java b/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizer.java index cb23843c..d52440e1 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizer.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/accessrules/AccessRulesCustomizer.java @@ -37,13 +37,17 @@ import lombok.extern.slf4j.Slf4j; /** - * {@link ServerHttpSecurityCustomizer} to apply {@link RoleBasedAccessRule ROLE - * based access rules} at startup. + * {@link ServerHttpSecurityCustomizer} responsible for applying + * {@link RoleBasedAccessRule role-based access rules} at application startup. *

- * The access rules are configured as - * {@link GatewayConfigProperties#getGlobalAccessRules() global rules}, and - * overridden if needed on a per-service basis from - * {@link GatewayConfigProperties#getServices()}. + * The access rules can be configured as: + *

    + *
  • {@link GatewayConfigProperties#getGlobalAccessRules() Global rules}, + * which apply to all services.
  • + *
  • Service-specific rules defined in + * {@link GatewayConfigProperties#getServices()}, which override the global + * rules for particular services.
  • + *
* * @see RoleBasedAccessRule * @see GatewayConfigProperties#getGlobalAccessRules() @@ -62,9 +66,8 @@ public void customize(ServerHttpSecurity http) { AuthorizeExchangeSpec authorizeExchange = http.authorizeExchange(); - // apply service-specific rules before global rules, order matters, and - // otherwise global path matches would be applied before service ones. - + // Apply service-specific rules before global rules. + // This ensures that service-specific paths take precedence over general rules. config.getServices().forEach((name, service) -> { log.info("Applying access rules for backend service '{}' at {}", name, service.getTarget()); apply(name, authorizeExchange, service.getAccessRules()); @@ -74,6 +77,13 @@ public void customize(ServerHttpSecurity http) { apply("global", authorizeExchange, config.getGlobalAccessRules()); } + /** + * Applies a set of access rules to the provided {@link AuthorizeExchangeSpec}. + * + * @param serviceName the name of the service being configured + * @param authorizeExchange the authorization configuration object + * @param accessRules the access rules to apply + */ private void apply(String serviceName, AuthorizeExchangeSpec authorizeExchange, List accessRules) { if (accessRules == null || accessRules.isEmpty()) { @@ -85,6 +95,36 @@ private void apply(String serviceName, AuthorizeExchangeSpec authorizeExchange, } } + /** + * Applies a {@link RoleBasedAccessRule} to the provided + * {@link AuthorizeExchangeSpec}, determining how access should be granted or + * restricted for specific URL patterns. + *

+ * This method evaluates the given access rule and configures the security + * filter chain accordingly by applying one of the following strategies: + *

+ *
    + *
  • If {@link RoleBasedAccessRule#isForbidden()} is {@code true}, access is + * completely denied.
  • + *
  • If {@link RoleBasedAccessRule#isAnonymous()} is {@code true}, the URLs + * are publicly accessible.
  • + *
  • If {@link RoleBasedAccessRule#getAllowedRoles()} is empty, access is + * granted to any authenticated user.
  • + *
  • Otherwise, access is restricted to users with at least one of the + * specified roles.
  • + *
+ *

+ * The URL patterns to which the rule applies are derived from + * {@link RoleBasedAccessRule#getInterceptUrl()}. + *

+ * + * @param authorizeExchange the authorization configuration object where rules + * are applied + * @param rule the access rule defining the URL patterns and access + * conditions + * @throws NullPointerException if the rule or its intercept URLs are null + * @throws IllegalArgumentException if the rule does not define any URL patterns + */ @VisibleForTesting void apply(AuthorizeExchangeSpec authorizeExchange, RoleBasedAccessRule rule) { final List antPatterns = resolveAntPatterns(rule); @@ -92,6 +132,7 @@ void apply(AuthorizeExchangeSpec authorizeExchange, RoleBasedAccessRule rule) { final boolean anonymous = rule.isAnonymous(); final List allowedRoles = rule.getAllowedRoles() == null ? List.of() : rule.getAllowedRoles(); Access access = authorizeExchange(authorizeExchange, antPatterns); + if (forbidden) { log.debug("Denying access to everyone for {}", antPatterns); denyAll(access); @@ -108,49 +149,93 @@ void apply(AuthorizeExchangeSpec authorizeExchange, RoleBasedAccessRule rule) { } } + /** + * Resolves the Ant-style URL patterns for a given access rule. + * + * @param rule the access rule containing the URL patterns + * @return the list of resolved URL patterns + */ private List resolveAntPatterns(RoleBasedAccessRule rule) { List antPatterns = rule.getInterceptUrl(); Objects.requireNonNull(antPatterns, "intercept-urls is null"); antPatterns.forEach(Objects::requireNonNull); - if (antPatterns.isEmpty()) + if (antPatterns.isEmpty()) { throw new IllegalArgumentException("No ant-pattern(s) defined for rule " + rule); - antPatterns.forEach(Objects::requireNonNull); + } return antPatterns; } + /** + * Configures URL-based authorization for a given set of patterns. + * + * @param authorizeExchange the security configuration object + * @param antPatterns the URL patterns to authorize + * @return the access configuration for the specified patterns + */ @VisibleForTesting Access authorizeExchange(AuthorizeExchangeSpec authorizeExchange, List antPatterns) { return authorizeExchange.pathMatchers(antPatterns.toArray(String[]::new)); } + /** + * Resolves the role names, ensuring they have the required prefix. + * + * @param antPatterns the URL patterns being configured + * @param allowedRoles the roles that should be granted access + * @return the list of role names with appropriate prefixes + */ private List resolveRoles(List antPatterns, List allowedRoles) { return allowedRoles.stream().map(this::ensureRolePrefix).toList(); } + /** + * Requires that the user be authenticated to access the configured path. + * + * @param access the access configuration object + */ @VisibleForTesting void requireAuthenticatedUser(Access access) { access.authenticated(); } + /** + * Grants access only if the user has at least one of the specified roles. + * + * @param access the access configuration object + * @param roles the list of roles required for access + */ @VisibleForTesting void hasAnyAuthority(Access access, List roles) { - // Checks against the effective set of rules (both provided by the Authorization - // service and derived from roles mappings) access.access( GeorchestraUserRolesAuthorizationManager.hasAnyAuthority(userMapper, roles.toArray(String[]::new))); - // access.hasAnyAuthority(roles.toArray(String[]::new)); } + /** + * Grants unrestricted access to the configured path. + * + * @param access the access configuration object + */ @VisibleForTesting void permitAll(Access access) { access.permitAll(); } + /** + * Denies access to all users for the configured path. + * + * @param access the access configuration object + */ @VisibleForTesting void denyAll(Access access) { access.denyAll(); } + /** + * Ensures that the given role name has the required {@code ROLE_} prefix. + * + * @param roleName the role name to check + * @return the role name with the {@code ROLE_} prefix if it was missing + */ private String ensureRolePrefix(@NonNull String roleName) { return roleName.startsWith("ROLE_") ? roleName : ("ROLE_" + roleName); } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedEmailFoundException.java b/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedEmailFoundException.java index 3e55b423..4aab159b 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedEmailFoundException.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedEmailFoundException.java @@ -18,13 +18,31 @@ */ package org.georchestra.gateway.security.exceptions; +/** + * Exception thrown when multiple user accounts are found with the same email + * address. + *

+ * This exception is used to indicate a conflict in user identity resolution, + * typically occurring during authentication or user synchronization processes. + *

+ */ @SuppressWarnings("serial") public class DuplicatedEmailFoundException extends RuntimeException { + /** + * Constructs a new {@code DuplicatedEmailFoundException} with the specified + * detail message. + * + * @param message the detail message + */ public DuplicatedEmailFoundException(String message) { super(message); } + /** + * Constructs a new {@code DuplicatedEmailFoundException} without a detail + * message. + */ public DuplicatedEmailFoundException() { } } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedUsernameFoundException.java b/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedUsernameFoundException.java index 4ec969fa..4353539f 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedUsernameFoundException.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/exceptions/DuplicatedUsernameFoundException.java @@ -18,13 +18,31 @@ */ package org.georchestra.gateway.security.exceptions; +/** + * Exception thrown when multiple user accounts are found with the same + * username. + *

+ * This exception is used to indicate a conflict in user identity resolution, + * typically occurring during authentication or user synchronization processes. + *

+ */ @SuppressWarnings("serial") public class DuplicatedUsernameFoundException extends RuntimeException { + /** + * Constructs a new {@code DuplicatedUsernameFoundException} with the specified + * detail message. + * + * @param message the detail message + */ public DuplicatedUsernameFoundException(String message) { super(message); } + /** + * Constructs a new {@code DuplicatedUsernameFoundException} without a detail + * message. + */ public DuplicatedUsernameFoundException() { } } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/AuthenticationProviderDecorator.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/AuthenticationProviderDecorator.java index a2bb2941..0d45934a 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/AuthenticationProviderDecorator.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/AuthenticationProviderDecorator.java @@ -26,19 +26,48 @@ import lombok.NonNull; import lombok.RequiredArgsConstructor; +/** + * Abstract decorator for {@link AuthenticationProvider} implementations. + *

+ * This class delegates authentication and support checks to another + * {@link AuthenticationProvider}, allowing additional processing in subclasses + * without modifying the original authentication logic. + *

+ * + * @see AuthenticationProvider + */ @RequiredArgsConstructor public abstract class AuthenticationProviderDecorator implements AuthenticationProvider { private final @NonNull AuthenticationProvider delegate; + /** + * Determines whether this {@link AuthenticationProvider} supports the specified + * authentication class. + * + * @param authentication the authentication class to check + * @return {@code true} if the provider supports the given authentication type, + * otherwise {@code false} + */ @Override public boolean supports(Class authentication) { return delegate.supports(authentication); } + /** + * Authenticates the given {@link Authentication} request. + *

+ * This method delegates authentication to the underlying + * {@link AuthenticationProvider}. + *

+ * + * @param authentication the authentication request + * @return a fully authenticated object, or {@code null} if authentication was + * unsuccessful + * @throws AuthenticationException if authentication fails + */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { return delegate.authenticate(authentication); } - -} \ No newline at end of file +} diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAuthenticationConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAuthenticationConfiguration.java index 78b41c37..3e21c465 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAuthenticationConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapAuthenticationConfiguration.java @@ -37,39 +37,42 @@ import org.springframework.security.authentication.ReactiveAuthenticationManagerAdapter; import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; -import org.springframework.security.ldap.userdetails.LdapUserDetails; import org.springframework.security.web.server.authentication.AuthenticationWebFilter; import org.springframework.security.web.server.util.matcher.ServerWebExchangeMatchers; import lombok.extern.slf4j.Slf4j; /** - * {@link ServerHttpSecurityCustomizer} to enable LDAP based authentication and + * {@link ServerHttpSecurityCustomizer} to enable LDAP-based authentication and * authorization across multiple LDAP databases. *

- * This configuration sets up the required beans for spring-based LDAP + * This configuration sets up the required beans for Spring-based LDAP * authentication and authorization, using * {@link GeorchestraGatewaySecurityConfigProperties} to get the * {@link GeorchestraGatewaySecurityConfigProperties#getUrl() connection URL} * and the {@link GeorchestraGatewaySecurityConfigProperties#getBaseDn() base * DN}. + *

*

- * As a result, the {@link ServerHttpSecurity} will have HTTP-Basic - * authentication enabled and {@link ServerHttpSecurity#formLogin() form login} - * set up. + * As a result, the {@link ServerHttpSecurity} will have HTTP Basic + * authentication enabled, as well as {@link ServerHttpSecurity#formLogin() form + * login}. + *

*

- * Upon successful authentication, the corresponding {@link Authentication} with - * an {@link LdapUserDetails} as {@link Authentication#getPrincipal() principal} - * and the roles extracted from LDAP as {@link Authentication#getAuthorities() - * authorities}, will be set as the security context's - * {@link SecurityContext#getAuthentication() authentication} property. + * Upon successful authentication, an {@link Authentication} instance will be + * set in the {@link org.springframework.security.core.context.SecurityContext + * SecurityContext} with an + * {@link org.springframework.security.ldap.userdetails.LdapUserDetails + * LdapUserDetails} as the principal and roles extracted from LDAP as + * authorities. + *

*

- * Note however, this may not be enough information to convey - * geOrchestra-specific HTTP request headers to backend services, depending on - * the matching gateway-route configuration. See - * {@link ExtendedLdapAuthenticationConfiguration} for further details. - * + * However, depending on the configured gateway routes, this may not be enough + * information to convey geOrchestra-specific HTTP request headers to backend + * services. See {@link ExtendedLdapAuthenticationConfiguration} for further + * details. + *

+ * * @see GeorchestraGatewaySecurityConfigProperties * @see BasicLdapAuthenticationConfiguration * @see ExtendedLdapAuthenticationConfiguration @@ -82,26 +85,63 @@ @Slf4j(topic = "org.georchestra.gateway.security.ldap") public class LdapAuthenticationConfiguration { + /** + * Enables HTTP Basic authentication and form login for LDAP authentication. + */ public static final class LDAPAuthenticationCustomizer implements ServerHttpSecurityCustomizer { + /** + * Configures HTTP Basic authentication and form login. + * + * @param http the {@link ServerHttpSecurity} instance + */ public @Override void customize(ServerHttpSecurity http) { log.info("Enabling HTTP Basic authentication support for LDAP"); http.httpBasic().and().formLogin(); } } + /** + * Registers an LDAP authentication customizer to enable HTTP Basic and form + * login. + * + * @return a {@link ServerHttpSecurityCustomizer} for LDAP authentication + */ @Bean ServerHttpSecurityCustomizer ldapHttpBasicLoginFormEnablerExtension() { return new LDAPAuthenticationCustomizer(); } + /** + * Creates an {@link AuthenticationWebFilter} for LDAP authentication. + *

+ * This filter is triggered when requests match the {@code /auth/login} path. + *

+ * + * @param ldapAuthenticationManager the {@link ReactiveAuthenticationManager} + * for LDAP authentication + * @return an {@link AuthenticationWebFilter} configured for LDAP authentication + */ @Bean AuthenticationWebFilter ldapAuthenticationWebFilter(ReactiveAuthenticationManager ldapAuthenticationManager) { - AuthenticationWebFilter ldapAuthFilter = new AuthenticationWebFilter(ldapAuthenticationManager); ldapAuthFilter.setRequiresAuthenticationMatcher(ServerWebExchangeMatchers.pathMatchers("/auth/login")); return ldapAuthFilter; } + /** + * Creates an LDAP authentication manager that combines multiple authentication + * providers. + *

+ * This manager supports both basic and extended LDAP authentication providers. + * If no providers are available, {@code null} is returned. + *

+ * + * @param basic a list of {@link BasicLdapAuthenticationProvider} instances + * @param extended a list of {@link GeorchestraLdapAuthenticationProvider} + * instances + * @return a {@link ReactiveAuthenticationManager} if providers are available, + * otherwise {@code null} + */ @Bean ReactiveAuthenticationManager ldapAuthenticationManager(List basic, List extended) { @@ -109,8 +149,11 @@ ReactiveAuthenticationManager ldapAuthenticationManager(List flattened = Stream.concat(basic.stream(), extended.stream()) .map(AuthenticationProvider.class::cast).toList(); - if (flattened.isEmpty()) + if (flattened.isEmpty()) { + log.warn("No LDAP authentication providers configured."); return null; + } + ProviderManager providerManager = new ProviderManager(flattened); return new ReactiveAuthenticationManagerAdapter(providerManager); } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigBuilder.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigBuilder.java index b286de8a..73de5185 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigBuilder.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigBuilder.java @@ -30,55 +30,79 @@ import lombok.extern.slf4j.Slf4j; /** + * Helper class to construct LDAP configuration objects for both basic and + * extended LDAP authentication mechanisms. + *

+ * This class is responsible for converting {@link Server} configuration objects + * into {@link LdapServerConfig} (basic LDAP) and {@link ExtendedLdapConfig} + * (extended LDAP) configurations. + *

*/ @Slf4j public class LdapConfigBuilder { + /** + * Builds a {@link LdapServerConfig} for basic LDAP authentication. + * + * @param name the name of the LDAP configuration + * @param config the {@link Server} configuration containing LDAP settings + * @return a fully initialized {@link LdapServerConfig} instance + */ public LdapServerConfig asBasicLdapConfig(String name, Server config) { String searchFilter = usersSearchFilter(name, config); - return LdapServerConfig.builder()// - .name(name)// - .enabled(config.isEnabled())// - .activeDirectory(config.isActiveDirectory())// - .url(config.getUrl())// - .baseDn(config.getBaseDn())// - .usersRdn(config.getUsers().getRdn())// - .usersSearchFilter(searchFilter)// - .returningAttributes(config.getUsers().getReturningAttributes())// - .rolesRdn(config.getRoles().getRdn())// - .rolesSearchFilter(config.getRoles().getSearchFilter())// - .adminDn(toOptional(config.getAdminDn()))// + return LdapServerConfig.builder().name(name).enabled(config.isEnabled()) + .activeDirectory(config.isActiveDirectory()).url(config.getUrl()).baseDn(config.getBaseDn()) + .usersRdn(config.getUsers().getRdn()).usersSearchFilter(searchFilter) + .returningAttributes(config.getUsers().getReturningAttributes()).rolesRdn(config.getRoles().getRdn()) + .rolesSearchFilter(config.getRoles().getSearchFilter()).adminDn(toOptional(config.getAdminDn())) .adminPassword(toOptional(config.getAdminPassword())).build(); } + /** + * Builds an {@link ExtendedLdapConfig} for extended LDAP authentication. + * + * @param name the name of the LDAP configuration + * @param config the {@link Server} configuration containing LDAP settings + * @return a fully initialized {@link ExtendedLdapConfig} instance + */ public ExtendedLdapConfig asExtendedLdapConfig(String name, Server config) { String searchFilter = usersSearchFilter(name, config); - return ExtendedLdapConfig.builder()// - .name(name)// - .enabled(config.isEnabled())// - .url(config.getUrl())// - .baseDn(config.getBaseDn())// - .usersRdn(config.getUsers().getRdn())// - .usersSearchFilter(searchFilter)// - .returningAttributes(config.getUsers().getReturningAttributes())// - .rolesRdn(config.getRoles().getRdn())// - .rolesSearchFilter(config.getRoles().getSearchFilter())// - .orgsRdn(config.getOrgs().getRdn())// - .pendingOrgsRdn(config.getOrgs().getPendingRdn())// - .adminDn(toOptional(config.getAdminDn()))// - .adminPassword(toOptional(config.getAdminPassword()))// - .build(); + return ExtendedLdapConfig.builder().name(name).enabled(config.isEnabled()).url(config.getUrl()) + .baseDn(config.getBaseDn()).usersRdn(config.getUsers().getRdn()).usersSearchFilter(searchFilter) + .returningAttributes(config.getUsers().getReturningAttributes()).rolesRdn(config.getRoles().getRdn()) + .rolesSearchFilter(config.getRoles().getSearchFilter()).orgsRdn(config.getOrgs().getRdn()) + .pendingOrgsRdn(config.getOrgs().getPendingRdn()).adminDn(toOptional(config.getAdminDn())) + .adminPassword(toOptional(config.getAdminPassword())).build(); } + /** + * Determines the user search filter for LDAP authentication. + *

+ * If no search filter is explicitly defined and the LDAP server is an Active + * Directory instance, the default Active Directory search filter is used. + *

+ * + * @param name the name of the LDAP configuration + * @param config the LDAP server configuration + * @return the user search filter string + */ private String usersSearchFilter(String name, Server config) { String searchFilter = config.getUsers().getSearchFilter(); if (!StringUtils.hasText(searchFilter) && config.isActiveDirectory()) { searchFilter = LdapServerConfig.DEFAULT_ACTIVE_DIRECTORY_USER_SEARCH_FILTER; - log.info("Using default search filter '{}' for AD config {}", searchFilter, name); + log.info("Using default search filter '{}' for Active Directory configuration: {}", searchFilter, name); } return searchFilter; } + /** + * Converts a string value to an {@link Optional}, returning an empty value if + * the string is null or empty. + * + * @param value the input string + * @return an {@link Optional} containing the string if it is not empty, + * otherwise empty + */ private Optional toOptional(String value) { return ofNullable(StringUtils.hasText(value) ? value : null); } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidations.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidations.java index 618bde88..e8937ca6 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidations.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidations.java @@ -26,21 +26,53 @@ import lombok.extern.slf4j.Slf4j; +/** + * Validator for LDAP configuration properties. + *

+ * This class ensures that necessary LDAP configuration fields are correctly + * defined based on the type of LDAP authentication used. + *

+ * + *

+ * Validation rules include: + *

+ *
    + *
  • Ensuring required properties such as URL and base DN are provided.
  • + *
  • Applying additional validation rules for extended LDAP + * configurations.
  • + *
  • Ensuring Active Directory configurations do not include unnecessary + * properties.
  • + *
+ */ @Slf4j(topic = "org.georchestra.gateway.security.ldap") public class LdapConfigPropertiesValidations { + /** + * Validates an LDAP configuration. + * + * @param name the LDAP configuration name + * @param config the {@link Server} configuration containing LDAP settings + * @param errors the {@link Errors} object for capturing validation errors + */ public void validate(String name, Server config, Errors errors) { if (!config.isEnabled()) { - log.debug("ignoring validation of LDAP config {}, enabled = false", name); + log.debug("Ignoring validation of LDAP config '{}', enabled = false", name); return; } + + // Ensure the LDAP URL is defined final String url = format("ldap.[%s].url", name); - rejectIfEmptyOrWhitespace(errors, url, "", "LDAP url is required (e.g.: ldap://my.ldap.com:389)"); + rejectIfEmptyOrWhitespace(errors, url, "", "LDAP URL is required (e.g., ldap://my.ldap.com:389)"); + // Validate base LDAP configuration validateSimpleLdap(name, config, errors); + + // Validate geOrchestra-specific extensions if enabled if (config.isExtended()) { validateGeorchestraExtensions(name, config, errors); } + + // Apply specific validation rules for Active Directory configurations if (config.isActiveDirectory()) { validateActiveDirectory(name, config, errors); } else { @@ -48,39 +80,76 @@ public void validate(String name, Server config, Errors errors) { } } + /** + * Validates essential LDAP properties for a standard LDAP configuration. + * + * @param name the LDAP configuration name + * @param config the LDAP server configuration + * @param errors the validation error object + */ private void validateSimpleLdap(String name, Server config, Errors errors) { rejectIfEmptyOrWhitespace(errors, format("ldap.[%s].baseDn", name), "", - "LDAP base DN is required. e.g.: dc=georchestra,dc=org"); + "LDAP base DN is required (e.g., dc=georchestra,dc=org)"); rejectIfEmptyOrWhitespace(errors, format("ldap.[%s].users.rdn", name), "", - "LDAP users RDN (Relative Distinguished Name) is required. e.g.: ou=users,dc=georchestra,dc=org"); + "LDAP users RDN (Relative Distinguished Name) is required (e.g., ou=users,dc=georchestra,dc=org)"); rejectIfEmptyOrWhitespace(errors, format("ldap.[%s].roles.rdn", name), "", - "Roles Relative distinguished name is required. e.g.: ou=roles"); + "Roles Relative Distinguished Name is required (e.g., ou=roles)"); rejectIfEmptyOrWhitespace(errors, format("ldap.[%s].roles.searchFilter", name), "", - "Roles searchFilter is required. e.g.: (member={0})"); + "Roles search filter is required (e.g., (member={0}))"); } + /** + * Ensures that the user search filter is mandatory for non-Active Directory + * configurations. + * + * @param name the LDAP configuration name + * @param errors the validation error object + */ private void validateUsersSearchFilterMandatory(String name, Errors errors) { rejectIfEmptyOrWhitespace(errors, format("ldap.[%s].users.searchFilter", name), "", - "LDAP users searchFilter is required for regular LDAP configs. e.g.: (uid={0}), and optional for Active Directory. e.g.: (&(objectClass=user)(userPrincipalName={0}))"); + "LDAP users search filter is required for standard LDAP configurations (e.g., (uid={0})), " + + "but optional for Active Directory (e.g., (&(objectClass=user)(userPrincipalName={0})))"); } + /** + * Validates geOrchestra-specific LDAP extensions. + * + * @param name the LDAP configuration name + * @param config the LDAP server configuration + * @param errors the validation error object + */ private void validateGeorchestraExtensions(String name, Server config, Errors errors) { rejectIfEmptyOrWhitespace(errors, format("ldap.[%s].orgs.rdn", name), "", - "Organizations search base RDN is required if extended is true. e.g.: ou=orgs"); + "Organizations search base RDN is required if 'extended' is true (e.g., ou=orgs)"); } + /** + * Ensures that Active Directory configurations do not contain unused + * properties. + * + * @param name the LDAP configuration name + * @param config the LDAP server configuration + * @param errors the validation error object + */ private void validateActiveDirectory(String name, Server config, Errors errors) { warnUnusedByActiveDirectory(name, "orgs", config.getOrgs()); } + /** + * Logs a warning if an Active Directory configuration contains an unused + * property. + * + * @param name the LDAP configuration name + * @param property the property name + * @param value the property value + */ private void warnUnusedByActiveDirectory(String name, String property, Object value) { if (value != null) { - log.warn( - "Found config property org.georchestra.gateway.security.ldap.{}.{} but it's not used by Active Directory", - name, property); + log.warn("Found config property 'org.georchestra.gateway.security.ldap.{}.{}', " + + "but it is not used by Active Directory configurations.", name, property); } } } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/NoPasswordLdapUserDetailsMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/NoPasswordLdapUserDetailsMapper.java index d80b4ad2..1cf54493 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/NoPasswordLdapUserDetailsMapper.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/NoPasswordLdapUserDetailsMapper.java @@ -1,9 +1,43 @@ +/* + * Copyright (C) 2022 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ package org.georchestra.gateway.security.ldap; import org.springframework.security.ldap.userdetails.LdapUserDetailsMapper; +/** + * Custom implementation of {@link LdapUserDetailsMapper} that prevents storing + * passwords in the security context. + *

+ * This class overrides the default password mapping behavior to always return + * {@code null}, ensuring that passwords retrieved from LDAP are not retained in + * memory. + *

+ */ public class NoPasswordLdapUserDetailsMapper extends LdapUserDetailsMapper { + /** + * Overrides the default password mapping to always return {@code null}, + * ensuring that the user's password is never stored in the security context. + * + * @param passwordValue the original password value from LDAP + * @return always {@code null} + */ @Override protected String mapPassword(Object passwordValue) { return null; diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticatedUserMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticatedUserMapper.java index 8d4c3936..ef5255b0 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticatedUserMapper.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticatedUserMapper.java @@ -36,43 +36,72 @@ import lombok.RequiredArgsConstructor; /** - * {@link GeorchestraUserMapperExtension} that maps generic LDAP-authenticated - * token to {@link GeorchestraUser} by calling - * {@link UsersApi#findByUsername(String)}, with the authentication token's - * principal name as argument. + * Maps a generic LDAP-authenticated {@link Authentication} token to a + * {@link GeorchestraUser}. + *

+ * This implementation extracts user details from an + * {@link LdapUserDetails}-based authentication and maps them to a + * {@link GeorchestraUser}. It retrieves: + *

    + *
  • Username from {@link LdapUserDetails#getUsername()}
  • + *
  • Roles from {@link Authentication#getAuthorities()}
  • + *
  • Additional attributes (first name, telephone, description) if available + * from a {@link Person} instance.
  • + *
+ *

+ * + *

+ * This mapper does not interact with {@link UsersApi}, unlike other + * implementations. + *

*/ @RequiredArgsConstructor public class BasicLdapAuthenticatedUserMapper implements GeorchestraUserMapperExtension { + /** + * Attempts to resolve a {@link GeorchestraUser} from the provided + * authentication token. + * + * @param authToken the authentication token to process + * @return an {@link Optional} containing the mapped {@link GeorchestraUser}, or + * empty if the token does not match the expected type + */ @Override public Optional resolve(Authentication authToken) { - return Optional.ofNullable(authToken)// - .filter(UsernamePasswordAuthenticationToken.class::isInstance) - .map(UsernamePasswordAuthenticationToken.class::cast)// - .filter(token -> token.getPrincipal() instanceof LdapUserDetails)// - .flatMap(this::map); + return Optional.ofNullable(authToken).filter(UsernamePasswordAuthenticationToken.class::isInstance) + .map(UsernamePasswordAuthenticationToken.class::cast) + .filter(token -> token.getPrincipal() instanceof LdapUserDetails).flatMap(this::map); } + /** + * Maps an LDAP-authenticated user to a {@link GeorchestraUser}. + * + * @param token the authentication token containing LDAP user details + * @return an {@link Optional} containing the mapped {@link GeorchestraUser} + */ Optional map(UsernamePasswordAuthenticationToken token) { - final LdapUserDetails principal = (LdapUserDetails) token.getPrincipal(); - final String username = principal.getUsername(); + LdapUserDetails principal = (LdapUserDetails) token.getPrincipal(); + String username = principal.getUsername(); List roles = resolveRoles(token.getAuthorities()); GeorchestraUser user = new GeorchestraUser(); user.setUsername(username); - user.setRoles(new ArrayList<>(roles));//mutable + user.setRoles(new ArrayList<>(roles)); // Ensure roles are mutable if (principal instanceof Person person) { - String description = person.getDescription(); - String givenName = person.getGivenName(); - String telephoneNumber = person.getTelephoneNumber(); - user.setNotes(description); - user.setFirstName(givenName); - user.setTelephoneNumber(telephoneNumber); + user.setFirstName(person.getGivenName()); + user.setTelephoneNumber(person.getTelephoneNumber()); + user.setNotes(person.getDescription()); } return Optional.of(user); } + /** + * Extracts role names from the authentication token's authorities. + * + * @param authorities the granted authorities assigned to the user + * @return a list of role names + */ protected List resolveRoles(Collection authorities) { return authorities.stream().map(GrantedAuthority::getAuthority).toList(); } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationConfiguration.java index db097cbf..0093087c 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationConfiguration.java @@ -21,45 +21,45 @@ import java.util.List; import org.georchestra.gateway.security.GeorchestraGatewaySecurityConfigProperties; -import org.georchestra.gateway.security.ServerHttpSecurityCustomizer; import org.georchestra.gateway.security.ldap.extended.ExtendedLdapAuthenticationConfiguration; import org.springframework.beans.factory.BeanCreationException; import org.springframework.boot.context.properties.EnableConfigurationProperties; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; -import org.springframework.security.config.web.server.ServerHttpSecurity; import org.springframework.security.core.Authentication; -import org.springframework.security.core.context.SecurityContext; import org.springframework.security.ldap.authentication.LdapAuthenticationProvider; import org.springframework.security.ldap.userdetails.LdapUserDetails; import lombok.extern.slf4j.Slf4j; /** - * {@link ServerHttpSecurityCustomizer} to enable LDAP based authentication and - * authorization across multiple LDAP databases. + * Configures LDAP-based authentication and authorization for geOrchestra + * Gateway. *

- * This configuration sets up the required beans for spring-based LDAP - * authentication and authorization, using - * {@link GeorchestraGatewaySecurityConfigProperties} to get the - * {@link GeorchestraGatewaySecurityConfigProperties#getUrl() connection URL} - * and the {@link GeorchestraGatewaySecurityConfigProperties#getBaseDn() base - * DN}. + * This configuration: + *

    + *
  • Loads LDAP server configurations from + * {@link GeorchestraGatewaySecurityConfigProperties}.
  • + *
  • Registers {@link BasicLdapAuthenticationProvider} instances for each + * enabled LDAP server.
  • + *
  • Provides a {@link BasicLdapAuthenticatedUserMapper} to convert LDAP + * authentication data into a geOrchestra user.
  • + *
+ *

*

- * As a result, the {@link ServerHttpSecurity} will have HTTP-Basic - * authentication enabled and {@link ServerHttpSecurity#formLogin() form login} - * set up. + * Authenticated users will have: + *

    + *
  • An {@link LdapUserDetails} principal extracted from their LDAP + * authentication.
  • + *
  • Roles assigned based on the LDAP group mappings.
  • + *
  • A security context populated with an {@link Authentication} object.
  • + *
+ *

*

- * Upon successful authentication, the corresponding {@link Authentication} with - * an {@link LdapUserDetails} as {@link Authentication#getPrincipal() principal} - * and the roles extracted from LDAP as {@link Authentication#getAuthorities() - * authorities}, will be set as the security context's - * {@link SecurityContext#getAuthentication() authentication} property. - *

- * Note however, this may not be enough information to convey - * geOrchestra-specific HTTP request headers to backend services, depending on - * the matching gateway-route configuration. See - * {@link ExtendedLdapAuthenticationConfiguration} for further details. + * This configuration primarily supports standard LDAP authentication. For + * geOrchestra-specific LDAP features (e.g., organizations, additional + * attributes), refer to {@link ExtendedLdapAuthenticationConfiguration}. + *

* * @see ExtendedLdapAuthenticationConfiguration * @see GeorchestraGatewaySecurityConfigProperties @@ -69,39 +69,63 @@ @Slf4j(topic = "org.georchestra.gateway.security.ldap.basic") public class BasicLdapAuthenticationConfiguration { + /** + * Provides an LDAP user mapper for basic authentication configurations. + * + * @param enabledConfigs the list of enabled simple LDAP configurations + * @return a {@link BasicLdapAuthenticatedUserMapper} instance or {@code null} + * if no LDAP configurations are enabled + */ @Bean BasicLdapAuthenticatedUserMapper ldapAuthenticatedUserMapper(List enabledConfigs) { return enabledConfigs.isEmpty() ? null : new BasicLdapAuthenticatedUserMapper(); } + /** + * Retrieves the list of enabled simple (non-extended) LDAP configurations. + * + * @param config the security configuration properties + * @return a list of enabled {@link LdapServerConfig} instances + */ @Bean List enabledSimpleLdapConfigs(GeorchestraGatewaySecurityConfigProperties config) { return config.simpleEnabled(); } + /** + * Creates a list of LDAP authentication providers based on the enabled LDAP + * configurations. + * + * @param configs the list of enabled LDAP configurations + * @return a list of {@link BasicLdapAuthenticationProvider} instances + */ @Bean List ldapAuthenticationProviders(List configs) { return configs.stream().map(this::createLdapProvider).toList(); } + /** + * Creates an {@link BasicLdapAuthenticationProvider} for a given LDAP + * configuration. + * + * @param config the LDAP server configuration + * @return an initialized {@link BasicLdapAuthenticationProvider} instance + * @throws BeanCreationException if an error occurs during provider creation + */ private BasicLdapAuthenticationProvider createLdapProvider(LdapServerConfig config) { - log.info("Creating LDAP AuthenticationProvider {} with URL {}", config.getName(), config.getUrl()); + log.info("Creating LDAP AuthenticationProvider '{}' with URL {}", config.getName(), config.getUrl()); try { - LdapAuthenticationProvider provider = new LdapAuthenticatorProviderBuilder()// - .url(config.getUrl())// - .baseDn(config.getBaseDn())// - .userSearchBase(config.getUsersRdn())// - .userSearchFilter(config.getUsersSearchFilter())// - .rolesSearchBase(config.getRolesRdn())// - .rolesSearchFilter(config.getRolesSearchFilter())// - .adminDn(config.getAdminDn().orElse(null))// - .adminPassword(config.getAdminPassword().orElse(null))// + LdapAuthenticationProvider provider = new LdapAuthenticatorProviderBuilder().url(config.getUrl()) + .baseDn(config.getBaseDn()).userSearchBase(config.getUsersRdn()) + .userSearchFilter(config.getUsersSearchFilter()).rolesSearchBase(config.getRolesRdn()) + .rolesSearchFilter(config.getRolesSearchFilter()).adminDn(config.getAdminDn().orElse(null)) + .adminPassword(config.getAdminPassword().orElse(null)) .returningAttributes(config.getReturningAttributes()).build(); return new BasicLdapAuthenticationProvider(config.getName(), provider); } catch (RuntimeException e) { - throw new BeanCreationException( - "Error creating LDAP Authentication Provider for config " + config + ": " + e.getMessage(), e); + throw new BeanCreationException("Error creating LDAP Authentication Provider for config " + config.getName() + + ": " + e.getMessage(), e); } } } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationProvider.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationProvider.java index 0b87a664..9eb566c8 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationProvider.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/BasicLdapAuthenticationProvider.java @@ -27,32 +27,73 @@ import lombok.NonNull; import lombok.extern.slf4j.Slf4j; +/** + * Decorates an {@link AuthenticationProvider} for basic LDAP authentication, + * adding logging and monitoring capabilities. + *

+ * This provider wraps a standard {@link AuthenticationProvider} to: + *

    + *
  • Log authentication attempts, successes, and failures for a specific LDAP + * configuration.
  • + *
  • Provide better traceability for multi-LDAP environments.
  • + *
+ *

+ *

+ * Example usage: + *

+ * + *
+ * {
+ *     @code
+ *     AuthenticationProvider ldapProvider = new BasicLdapAuthenticationProvider("ldap1", delegateProvider);
+ * }
+ * 
+ */ @Slf4j(topic = "org.georchestra.gateway.security.ldap") public class BasicLdapAuthenticationProvider extends AuthenticationProviderDecorator { private final @NonNull String configName; + /** + * Constructs a new {@code BasicLdapAuthenticationProvider} that decorates the + * given delegate. + * + * @param configName the name of the LDAP configuration (used for logging and + * identification) + * @param delegate the actual {@link AuthenticationProvider} handling the + * authentication + */ public BasicLdapAuthenticationProvider(@NonNull String configName, @NonNull AuthenticationProvider delegate) { super(delegate); this.configName = configName; } + /** + * Attempts to authenticate a user against the configured LDAP server. + *

+ * Logs authentication attempts, successes, and failures. + *

+ * + * @param authentication the authentication request object + * @return the authenticated {@link Authentication} object if authentication is + * successful + * @throws AuthenticationException if authentication fails + */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { - log.debug("Attempting to authenticate user {} against {} LDAP", authentication.getName(), configName); + log.debug("Attempting to authenticate user '{}' against '{}' LDAP", authentication.getName(), configName); try { Authentication auth = super.authenticate(authentication); - log.debug("Authenticated {} from {} with roles {}", auth.getName(), configName, auth.getAuthorities()); + log.debug("Authenticated '{}' from '{}' with roles {}", auth.getName(), configName, auth.getAuthorities()); return auth; } catch (AuthenticationException e) { if (log.isDebugEnabled()) { - log.info("Authentication of {} against {} LDAP failed", authentication.getName(), configName, e); + log.info("Authentication of '{}' against '{}' LDAP failed", authentication.getName(), configName, e); } else { - log.info("Authentication of {} against {} LDAP failed: {}", authentication.getName(), configName, + log.info("Authentication of '{}' against '{}' LDAP failed: {}", authentication.getName(), configName, e.getMessage()); } throw e; } } - } \ No newline at end of file diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapAuthenticatorProviderBuilder.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapAuthenticatorProviderBuilder.java index f6bbd97c..c46f97d6 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapAuthenticatorProviderBuilder.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapAuthenticatorProviderBuilder.java @@ -35,6 +35,32 @@ import lombok.experimental.Accessors; /** + * Builder for creating an {@link ExtendedLdapAuthenticationProvider} instance + * with a configurable LDAP authentication setup. + *

+ * This builder allows setting: + *

    + *
  • LDAP connection properties (URL, base DN, admin credentials)
  • + *
  • User search configuration (search base, filter, returning + * attributes)
  • + *
  • Role resolution configuration (group search base and filter)
  • + *
  • Integration with an optional {@link AccountDao} for user account + * management
  • + *
+ *

+ * + *

+ * Example usage: + *

+ * + *
+ * {
+ *     @code
+ *     LdapAuthenticationProvider provider = new LdapAuthenticatorProviderBuilder().url("ldap://example.com")
+ *             .baseDn("dc=example,dc=com").userSearchBase("ou=users").userSearchFilter("(uid={0})")
+ *             .rolesSearchBase("ou=groups").rolesSearchFilter("(member={0})").build();
+ * }
+ * 
*/ @Accessors(chain = true, fluent = true) public class LdapAuthenticatorProviderBuilder { @@ -53,34 +79,49 @@ public class LdapAuthenticatorProviderBuilder { private @Setter AccountDao accountDao; - // null = all atts, empty == none + /** + * Attributes to be retrieved when querying LDAP for user details. + *

+ * A {@code null} value retrieves all attributes, while an empty array retrieves + * none. + *

+ */ private @Setter String[] returningAttributes = null; + /** + * Builds and returns an {@link ExtendedLdapAuthenticationProvider} based on the + * configured settings. + * + * @return an LDAP authentication provider + * @throws NullPointerException if required fields are not set + */ public ExtendedLdapAuthenticationProvider build() { - requireNonNull(url, "url is not set"); - requireNonNull(baseDn, "baseDn is not set"); - requireNonNull(userSearchBase, "userSearchBase is not set"); - requireNonNull(userSearchFilter, "userSearchFilter is not set"); - requireNonNull(rolesSearchBase, "rolesSearchBase is not set"); - requireNonNull(rolesSearchFilter, "rolesSearchFilter is not set"); - - final ExtendedPasswordPolicyAwareContextSource source = contextSource(); - final BindAuthenticator authenticator = ldapAuthenticator(source); - final DefaultLdapAuthoritiesPopulator rolesPopulator = ldapAuthoritiesPopulator(source); + requireNonNull(url, "LDAP URL is not set"); + requireNonNull(baseDn, "Base DN is not set"); + requireNonNull(userSearchBase, "User search base is not set"); + requireNonNull(userSearchFilter, "User search filter is not set"); + requireNonNull(rolesSearchBase, "Roles search base is not set"); + requireNonNull(rolesSearchFilter, "Roles search filter is not set"); + + final ExtendedPasswordPolicyAwareContextSource contextSource = createContextSource(); + final BindAuthenticator authenticator = createLdapAuthenticator(contextSource); + final DefaultLdapAuthoritiesPopulator rolesPopulator = createLdapAuthoritiesPopulator(contextSource); + ExtendedLdapAuthenticationProvider provider = new ExtendedLdapAuthenticationProvider(authenticator, rolesPopulator); - - final GrantedAuthoritiesMapper rolesMapper = ldapAuthoritiesMapper(); - provider.setAuthoritiesMapper(rolesMapper); + provider.setAuthoritiesMapper(createAuthoritiesMapper()); provider.setUserDetailsContextMapper(new NoPasswordLdapUserDetailsMapper()); provider.setAccountDao(accountDao); + return provider; } - private BindAuthenticator ldapAuthenticator(BaseLdapPathContextSource contextSource) { + /** + * Creates and configures the LDAP authenticator. + */ + private BindAuthenticator createLdapAuthenticator(BaseLdapPathContextSource contextSource) { FilterBasedLdapUserSearch search = new FilterBasedLdapUserSearch(userSearchBase, userSearchFilter, contextSource); - search.setReturningAttributes(returningAttributes); BindAuthenticator authenticator = new BindAuthenticator(contextSource); @@ -89,7 +130,10 @@ private BindAuthenticator ldapAuthenticator(BaseLdapPathContextSource contextSou return authenticator; } - private ExtendedPasswordPolicyAwareContextSource contextSource() { + /** + * Creates and configures the LDAP context source for authentication. + */ + private ExtendedPasswordPolicyAwareContextSource createContextSource() { ExtendedPasswordPolicyAwareContextSource context = new ExtendedPasswordPolicyAwareContextSource(url); context.setBase(baseDn); if (adminDn != null) { @@ -100,11 +144,18 @@ private ExtendedPasswordPolicyAwareContextSource contextSource() { return context; } - private GrantedAuthoritiesMapper ldapAuthoritiesMapper() { + /** + * Creates a default authority mapper to convert LDAP roles into Spring Security + * authorities. + */ + private GrantedAuthoritiesMapper createAuthoritiesMapper() { return new SimpleAuthorityMapper(); } - private DefaultLdapAuthoritiesPopulator ldapAuthoritiesPopulator(BaseLdapPathContextSource contextSource) { + /** + * Creates and configures the LDAP role populator. + */ + private DefaultLdapAuthoritiesPopulator createLdapAuthoritiesPopulator(BaseLdapPathContextSource contextSource) { DefaultLdapAuthoritiesPopulator authoritiesPopulator = new DefaultLdapAuthoritiesPopulator(contextSource, rolesSearchBase); authoritiesPopulator.setGroupSearchFilter(rolesSearchFilter); diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapServerConfig.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapServerConfig.java index 1179b9e7..688328e9 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapServerConfig.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/basic/LdapServerConfig.java @@ -16,7 +16,6 @@ * You should have received a copy of the GNU General Public License along with * geOrchestra. If not, see . */ - package org.georchestra.gateway.security.ldap.basic; import java.util.Optional; @@ -26,26 +25,130 @@ import lombok.NonNull; import lombok.Value; +/** + * Configuration object representing the LDAP server settings used for user + * authentication and role retrieval. + *

+ * This class defines essential connection parameters and search configurations + * for LDAP authentication. + *

+ * + *

+ * Features: + *

    + *
  • Supports both standard LDAP and Active Directory.
  • + *
  • Allows specifying search filters and base DNs for users and roles.
  • + *
  • Supports optional admin credentials for LDAP queries.
  • + *
  • Provides a default Active Directory search filter.
  • + *
+ *

+ * + * Example usage: + * + *
+ * {
+ *     @code
+ *     LdapServerConfig config = LdapServerConfig.builder().name("default").enabled(true).activeDirectory(false)
+ *             .url("ldap://example.com").baseDn("dc=example,dc=com").usersRdn("ou=users")
+ *             .usersSearchFilter("(uid={0})").rolesRdn("ou=roles").rolesSearchFilter("(member={0})")
+ *             .adminDn(Optional.of("cn=admin,dc=example,dc=com")).adminPassword(Optional.of("secret")).build();
+ * }
+ * 
+ */ @Value @Builder @Generated public class LdapServerConfig { + + /** + * Default search filter for Active Directory user lookup. + */ public static final String DEFAULT_ACTIVE_DIRECTORY_USER_SEARCH_FILTER = "(&(objectClass=user)(userPrincipalName={0}))"; + /** + * Logical name for identifying the LDAP configuration. + */ private @NonNull String name; + + /** + * Flag indicating if this LDAP configuration is enabled. + */ private boolean enabled; + + /** + * Indicates whether the LDAP server is an Active Directory instance. + */ private boolean activeDirectory; + /** + * LDAP server URL, including protocol and port (e.g., + * "ldap://ldap.example.com:389"). + */ private @NonNull String url; + + /** + * Base Distinguished Name (DN) for the LDAP directory. + *

+ * This is the root DN where searches for users and roles begin. Example: + * {@code dc=example,dc=com}. + *

+ */ private @NonNull String baseDn; + /** + * Relative Distinguished Name (RDN) for user entries within the directory. + *

+ * Example: {@code ou=users}. + *

+ */ private @NonNull String usersRdn; + + /** + * LDAP search filter for locating user entries. + *

+ * Example: + *

    + *
  • OpenLDAP: {@code (uid={0})}
  • + *
  • Active Directory: + * {@code (&(objectClass=user)(userPrincipalName={0}))}
  • + *
+ *

+ */ private @NonNull String usersSearchFilter; + + /** + * Relative Distinguished Name (RDN) for role entries within the directory. + *

+ * Example: {@code ou=roles}. + *

+ */ private @NonNull String rolesRdn; + + /** + * LDAP search filter for retrieving user roles. + *

+ * Example: {@code (member={0})}. + *

+ */ private @NonNull String rolesSearchFilter; - // null = all atts, empty == none + + /** + * Attributes to retrieve when searching for user details. + *

+ * A {@code null} value means all attributes are retrieved, while an empty array + * means none are returned. + *

+ */ private String[] returningAttributes; + /** + * Optional distinguished name (DN) for an LDAP administrator account used for + * privileged queries. + */ private @NonNull Optional adminDn; + + /** + * Optional password for the administrator account used for privileged queries. + */ private @NonNull Optional adminPassword; -} \ No newline at end of file +} diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/DemultiplexingUsersApi.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/DemultiplexingUsersApi.java index 177f9685..dfaea626 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/DemultiplexingUsersApi.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/DemultiplexingUsersApi.java @@ -16,7 +16,6 @@ * You should have received a copy of the GNU General Public License along with * geOrchestra. If not, see . */ - package org.georchestra.gateway.security.ldap.extended; import java.util.HashSet; @@ -36,74 +35,142 @@ import lombok.RequiredArgsConstructor; /** - * Demultiplexer to call the appropriate {@link UsersApi} based on the - * authentication's service name, as provided by - * {@link GeorchestraUserNamePasswordAuthenticationToken#getConfigName()}, - * matching a configured LDAP database through the configuration properties - * {@code georchestra.gateway.security..*}. + * A service responsible for selecting the appropriate {@link UsersApi} based on + * the authentication's originating LDAP configuration, ensuring user lookups + * occur in the correct LDAP database. + *

+ * This class provides methods to: + *

    + *
  • Retrieve user details based on username, ensuring queries are made to the + * LDAP service where authentication originated.
  • + *
  • Resolve the user's organization information using the corresponding + * {@link OrganizationsApi}.
  • + *
  • Handle OAuth2-based user identification.
  • + *
+ *

+ * *

- * Ensures {@link GeorchestraLdapAuthenticatedUserMapper} queries the same LDAP - * database the authentication object was created from, avoiding the need to - * disambiguate if two configured LDAP databases have accounts with the same - * {@literal username}. + * The mapping between LDAP configuration names and their corresponding APIs is + * established through configuration properties following the pattern: + * {@code georchestra.gateway.security..*}. + *

+ * + * Example usage: + * + *
+ * {
+ *     @code
+ *     Optional user = demultiplexer.findByUsername("ldap-service-1", "jdoe");
+ * }
+ * 
+ * + * @see GeorchestraUser + * @see ExtendedGeorchestraUser + * @see UsersApi + * @see OrganizationsApi */ @RequiredArgsConstructor public class DemultiplexingUsersApi { + /** + * Mapping between service names and their corresponding {@link UsersApi} + * instances. + */ private final @NonNull Map usersByConfigName; + + /** + * Mapping between service names and their corresponding + * {@link OrganizationsApi} instances. + */ private final @NonNull Map orgsByConfigName; + /** + * Retrieves the set of configured service names. + * + * @return a set containing all registered LDAP service names. + */ public @VisibleForTesting Set getTargetNames() { return new HashSet<>(usersByConfigName.keySet()); } /** + * Finds a user by username within a specific LDAP service. * - * @param serviceName the configured LDAP service name, as from the - * configuration properties - * {@code georchestra.gateway.security.} - * @param username the user name to query the service's {@link UsersApi} with - * - * @return the {@link GeorchestraUser} returned by the service's - * {@link UsersApi}, or {@link Optional#empty() empty} if not found + * @param serviceName the LDAP service configuration name. + * @param username the username to search for. + * @return an {@link Optional} containing the {@link ExtendedGeorchestraUser}, + * or empty if the user is not found. + * @throws NullPointerException if no {@link UsersApi} is registered for the + * given service. */ public Optional findByUsername(@NonNull String serviceName, @NonNull String username) { - UsersApi usersApi = usersByConfigName.get(serviceName); - Objects.requireNonNull(usersApi, () -> "No UsersApi found for config named " + serviceName); - Optional user = usersApi.findByUsername(username); + UsersApi usersApi = Objects.requireNonNull(usersByConfigName.get(serviceName), + () -> "No UsersApi found for config named " + serviceName); - return extend(serviceName, user); + Optional user = usersApi.findByUsername(username); + return extendUserWithOrganization(serviceName, user); } + /** + * Finds a user by username in the first registered LDAP service. + *

+ * This method is useful when only one LDAP service is expected, but may not be + * reliable when multiple LDAP configurations exist. + *

+ * + * @param username the username to search for. + * @return an {@link Optional} containing the {@link ExtendedGeorchestraUser}, + * or empty if the user is not found. + */ public Optional findByUsername(@NonNull String username) { - // TODO: iterates over every possible geOrchestra LDAP being registered ? - // I would expect to have generally only one geOrchestra (extended) LDAP - // configured, - // so the first extended LDAP should do. - String serviceName = usersByConfigName.keySet().stream().findFirst().get(); - UsersApi usersApi = usersByConfigName.get(serviceName); - Optional user = usersApi.findByUsername(username); - - return extend(serviceName, user); + return usersByConfigName.keySet().stream().findFirst() + .flatMap(serviceName -> findByUsername(serviceName, username)); } + /** + * Finds a user by their OAuth2 provider and unique identifier. + *

+ * This method attempts to match an OAuth2-authenticated user within the first + * registered LDAP service. + *

+ * + * @param oauth2Provider the OAuth2 provider name (e.g., "google", "github"). + * @param oauth2Uid the unique identifier for the user within the OAuth2 + * provider. + * @return an {@link Optional} containing the {@link ExtendedGeorchestraUser}, + * or empty if the user is not found. + * @throws NullPointerException if no {@link UsersApi} is registered for the + * selected service. + */ public Optional findByOAuth2Uid(@NonNull String oauth2Provider, @NonNull String oauth2Uid) { - String serviceName = usersByConfigName.keySet().stream().findFirst().get(); - UsersApi usersApi = usersByConfigName.get(serviceName); - Objects.requireNonNull(usersApi, () -> "No UsersApi found for config named " + serviceName); - Optional user = usersApi.findByOAuth2Uid(oauth2Provider, oauth2Uid); + return usersByConfigName.keySet().stream().findFirst().flatMap(serviceName -> { + UsersApi usersApi = Objects.requireNonNull(usersByConfigName.get(serviceName), + () -> "No UsersApi found for config named " + serviceName); - return extend(serviceName, user); + Optional user = usersApi.findByOAuth2Uid(oauth2Provider, oauth2Uid); + return extendUserWithOrganization(serviceName, user); + }); } - private Optional extend(String serviceName, Optional user) { - OrganizationsApi orgsApi = orgsByConfigName.get(serviceName); - Objects.requireNonNull(orgsApi, () -> "No OrganizationsApi found for config named " + serviceName); + /** + * Extends a {@link GeorchestraUser} by attaching its corresponding organization + * details. + * + * @param serviceName the LDAP service configuration name. + * @param user the resolved user, if present. + * @return an {@link Optional} containing the {@link ExtendedGeorchestraUser} + * with organization details. + * @throws NullPointerException if no {@link OrganizationsApi} is registered for + * the given service. + */ + private Optional extendUserWithOrganization(String serviceName, + Optional user) { + OrganizationsApi orgsApi = Objects.requireNonNull(orgsByConfigName.get(serviceName), + () -> "No OrganizationsApi found for config named " + serviceName); Organization org = user.map(GeorchestraUser::getOrganization).flatMap(orgsApi::findByShortName).orElse(null); return user.map(ExtendedGeorchestraUser::new).map(u -> u.setOrg(org)); } - } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedGeorchestraUser.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedGeorchestraUser.java index 1e3ecb13..2ab2fc99 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedGeorchestraUser.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedGeorchestraUser.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2022 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ package org.georchestra.gateway.security.ldap.extended; import org.georchestra.security.model.GeorchestraUser; @@ -13,19 +31,70 @@ import lombok.experimental.Delegate; /** - * {@link GeorchestraUser} with resolved {@link #getOrg() Organization} + * An extended version of {@link GeorchestraUser} that includes an associated + * {@link Organization}. + *

+ * This class wraps an existing {@link GeorchestraUser} instance while adding an + * {@link #org} property, which represents the user's resolved organization. + * This is useful for systems where user information is stored separately from + * organizational details. + *

+ * + *

Example Usage:

+ * + *
+ * {
+ *     @code
+ *     GeorchestraUser baseUser = new GeorchestraUser();
+ *     baseUser.setUsername("jdoe");
+ * 
+ *     Organization org = new Organization();
+ *     org.setName("GeoOrg");
+ * 
+ *     ExtendedGeorchestraUser extendedUser = new ExtendedGeorchestraUser(baseUser);
+ *     extendedUser.setOrg(org);
+ * 
+ *     System.out.println(extendedUser.getUsername()); // Inherited from GeorchestraUser
+ *     System.out.println(extendedUser.getOrg().getName()); // "GeoOrg"
+ * }
+ * 
+ * + *

Key Features:

+ *
    + *
  • Delegates all calls to the wrapped {@link GeorchestraUser} instance.
  • + *
  • Allows seamless access to user properties while adding an + * {@link Organization} field.
  • + *
  • Uses {@link JsonIgnore} to prevent unnecessary serialization of sensitive + * fields.
  • + *
  • Overrides {@link #equals(Object)} and {@link #hashCode()} to ensure + * consistent equality checks.
  • + *
*/ @SuppressWarnings("serial") @RequiredArgsConstructor @Accessors(chain = true) public class ExtendedGeorchestraUser extends GeorchestraUser { + /** + * The underlying {@link GeorchestraUser} instance, which is fully delegated. + */ @JsonIgnore private final @NonNull @Delegate GeorchestraUser user; + /** + * The organization associated with this user. + */ @JsonIgnore private @Getter @Setter Organization org; + /** + * Compares this user to another object based on the properties of the + * underlying {@link GeorchestraUser}. + * + * @param o the object to compare against + * @return {@code true} if the object is a {@link GeorchestraUser} and has the + * same properties, otherwise {@code false} + */ public @Override boolean equals(Object o) { if (!(o instanceof GeorchestraUser)) { return false; @@ -33,6 +102,11 @@ public class ExtendedGeorchestraUser extends GeorchestraUser { return super.equals(o); } + /** + * Computes the hash code based on the underlying {@link GeorchestraUser}. + * + * @return the hash code value for this user + */ public @Override int hashCode() { return super.hashCode(); } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationConfiguration.java index 7a1b39b5..01a795a7 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationConfiguration.java @@ -1,5 +1,3 @@ -package org.georchestra.gateway.security.ldap.extended; - /* * Copyright (C) 2022 by the geOrchestra PSC * @@ -19,6 +17,8 @@ * geOrchestra. If not, see . */ +package org.georchestra.gateway.security.ldap.extended; + import static java.util.Objects.requireNonNull; import java.util.Collections; @@ -57,33 +57,77 @@ import lombok.extern.slf4j.Slf4j; /** - * Sets up a {@link GeorchestraUserMapperExtension} that knows how to map an - * authentication credentials given by a - * {@link GeorchestraUserNamePasswordAuthenticationToken} with an - * {@link LdapUserDetails} (i.e., if the user authenticated with LDAP), to a - * {@link GeorchestraUser}, making use of geOrchestra's - * {@literal georchestra-ldap-account-management} module's {@link UsersApi}. + * Configures authentication against an extended LDAP directory, supporting + * geOrchestra-specific attributes such as organizations and roles. + *

+ * This configuration provides a {@link GeorchestraUserMapperExtension} to + * transform an authenticated {@link LdapUserDetails} into a + * {@link GeorchestraUser}, leveraging geOrchestra's LDAP-based user management + * APIs. */ @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties(GeorchestraGatewaySecurityConfigProperties.class) @Slf4j(topic = "org.georchestra.gateway.security.ldap.extended") public class ExtendedLdapAuthenticationConfiguration { + /** + * Registers a user mapper that resolves LDAP-authenticated users to + * {@link GeorchestraUser}. + * + * @param users The {@link DemultiplexingUsersApi} used to look up users in + * different LDAP directories. + * @return A {@link GeorchestraLdapAuthenticatedUserMapper} instance if LDAP + * authentication is enabled; otherwise, returns {@code null}. + */ @Bean GeorchestraLdapAuthenticatedUserMapper georchestraLdapAuthenticatedUserMapper(DemultiplexingUsersApi users) { return users.getTargetNames().isEmpty() ? null : new GeorchestraLdapAuthenticatedUserMapper(users); } + /** + * Retrieves the list of enabled extended LDAP configurations. + * + * @param config The global security configuration properties. + * @return A list of enabled extended LDAP configurations. + */ @Bean List enabledExtendedLdapConfigs(GeorchestraGatewaySecurityConfigProperties config) { return config.extendedEnabled(); } + /** + * Creates authentication providers for each enabled extended LDAP + * configuration. + * + * @param configs A list of enabled extended LDAP configurations. + * @return A list of configured {@link GeorchestraLdapAuthenticationProvider} + * instances. + */ @Bean List extendedLdapAuthenticationProviders(List configs) { return configs.stream().map(this::createLdapProvider).toList(); } + /** + * Creates a {@link GeorchestraLdapAuthenticationProvider} for the given + * {@link ExtendedLdapConfig} by setting up the necessary LDAP authentication + * and authorization mechanisms. + *

+ * This method initializes an {@link LdapTemplate} and an {@link AccountDao} + * based on the given LDAP configuration. It then builds an + * {@link ExtendedLdapAuthenticationProvider} using an + * {@link LdapAuthenticatorProviderBuilder}, setting up the authentication + * provider with user and role search filters, as well as optional admin + * credentials if provided. + *

+ * + * @param config The {@link ExtendedLdapConfig} defining the LDAP connection + * details and search configurations. + * @return A configured {@link GeorchestraLdapAuthenticationProvider} for + * handling authentication against the specified LDAP server. + * @throws IllegalStateException if an error occurs while creating the LDAP + * authentication provider. + */ private GeorchestraLdapAuthenticationProvider createLdapProvider(ExtendedLdapConfig config) { log.info("Creating extended LDAP AuthenticationProvider {} at {}", config.getName(), config.getUrl()); @@ -103,10 +147,17 @@ private GeorchestraLdapAuthenticationProvider createLdapProvider(ExtendedLdapCon .returningAttributes(config.getReturningAttributes()).accountDao(accountsDao).build(); return new GeorchestraLdapAuthenticationProvider(config.getName(), delegate); } catch (Exception e) { - throw new RuntimeException(e); + throw new IllegalStateException(e); } } + /** + * Registers a {@link DemultiplexingUsersApi} that routes user API calls to the + * appropriate LDAP instance based on configuration. + * + * @param configs The list of extended LDAP configurations. + * @return A {@link DemultiplexingUsersApi} instance. + */ @Bean DemultiplexingUsersApi demultiplexingUsersApi(List configs) { Map usersByConfigName = new HashMap<>(); @@ -132,7 +183,7 @@ DemultiplexingUsersApi demultiplexingUsersApi(List configs) ////////////////////////////////////////////// private OrganizationsApi createOrgsApi(ExtendedLdapConfig ldapConfig, LdapTemplate ldapTemplate, - AccountDao accountsDao) throws Exception { + AccountDao accountsDao) { OrganizationsApiImpl impl = new OrganizationsApiImpl(); OrgsDaoImpl orgsDao = new OrgsDaoImpl(); orgsDao.setLdapTemplate(ldapTemplate); @@ -145,12 +196,11 @@ private OrganizationsApi createOrgsApi(ExtendedLdapConfig ldapConfig, LdapTempla return impl; } - private UsersApi createUsersApi(ExtendedLdapConfig ldapConfig, LdapTemplate ldapTemplate, AccountDao accountsDao) - throws Exception { + private UsersApi createUsersApi(ExtendedLdapConfig ldapConfig, LdapTemplate ldapTemplate, AccountDao accountsDao) { final RoleDao roleDao = roleDao(ldapTemplate, ldapConfig, accountsDao); final UserMapper ldapUserMapper = createUserMapper(roleDao); - UserRule userRule = ldapUserRule(ldapConfig); + UserRule userRule = ldapUserRule(); UsersApiImpl impl = new UsersApiImpl(); impl.setAccountsDao(accountsDao); @@ -190,9 +240,7 @@ private AccountDao accountsDao(LdapTemplate ldapTemplate, ExtendedLdapConfig lda impl.setBasePath(baseDn); impl.setUserSearchBaseDN(userSearchBaseDN); impl.setRoleSearchBaseDN(roleSearchBaseDN); - if (pendingUsersSearchBaseDN != null) { - impl.setPendingUserSearchBaseDN(pendingUsersSearchBaseDN); - } + impl.setPendingUserSearchBaseDN(pendingUsersSearchBaseDN); String orgSearchBaseDN = ldapConfig.getOrgsRdn(); requireNonNull(orgSearchBaseDN); @@ -213,7 +261,7 @@ private RoleDao roleDao(LdapTemplate ldapTemplate, ExtendedLdapConfig ldapConfig impl.setLdapTemplate(ldapTemplate); impl.setRoleSearchBaseDN(rolesRdn); impl.setAccountDao(accountDao); - impl.setRoles(ldapProtectedRoles(ldapConfig)); + impl.setRoles(ldapProtectedRoles()); return impl; } @@ -225,17 +273,11 @@ private OrgsDao orgsDao(LdapTemplate ldapTemplate, Server ldapConfig) { impl.setOrgSearchBaseDN(ldapConfig.getOrgs().getRdn()); final String pendingOrgSearchBaseDN = "ou=pendingorgs"; - - // not needed here, only console cares, we shouldn't allow to authenticate - // pending users, should we? impl.setPendingOrgSearchBaseDN(pendingOrgSearchBaseDN); - // not needed here, only console's OrgsController cares about this, right? - // final String orgTypes = "Association,Company,NGO,Individual,Other"; - // impl.setOrgTypeValues(orgTypes); return impl; } - private UserRule ldapUserRule(ExtendedLdapConfig ldapConfig) { + private UserRule ldapUserRule() { // we can't possibly try to delete a protected user, so no need to configure // them List protectedUsers = Collections.emptyList(); @@ -244,7 +286,7 @@ private UserRule ldapUserRule(ExtendedLdapConfig ldapConfig) { return rule; } - private RoleProtected ldapProtectedRoles(ExtendedLdapConfig ldapConfig) { + private RoleProtected ldapProtectedRoles() { // protected roles are used by the console service to avoid deleting them. This // application will never try to do so, so we don't care about configuring them List protectedRoles = List.of(); diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationProvider.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationProvider.java index 921e2262..634e2076 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationProvider.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapAuthenticationProvider.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2022 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ package org.georchestra.gateway.security.ldap.extended; import org.georchestra.ds.DataServiceException; @@ -16,31 +34,71 @@ import org.springframework.util.Assert; import org.springframework.util.StringUtils; +/** + * Custom LDAP authentication provider that extends + * {@link LdapAuthenticationProvider} to support user lookups by email and + * additional account resolution logic. + *

+ * This provider first attempts to resolve the authenticated user's email to a + * corresponding LDAP account via {@link AccountDao}. If a match is found, + * authentication proceeds using the associated UID instead of the provided + * email. + *

+ * This approach ensures that users can log in with their email addresses while + * maintaining compatibility with LDAP-based user identification. + */ public class ExtendedLdapAuthenticationProvider extends LdapAuthenticationProvider { private AccountDao accountDao; + /** + * Constructs an {@link ExtendedLdapAuthenticationProvider} using the specified + * {@link LdapAuthenticator} and {@link LdapAuthoritiesPopulator}. + * + * @param authenticator the {@link LdapAuthenticator} used for + * authentication + * @param authoritiesPopulator the {@link LdapAuthoritiesPopulator} used to load + * user authorities + */ public ExtendedLdapAuthenticationProvider(LdapAuthenticator authenticator, LdapAuthoritiesPopulator authoritiesPopulator) { super(authenticator, authoritiesPopulator); } + /** + * Sets the {@link AccountDao} used to resolve accounts by email. + * + * @param accountDao the {@link AccountDao} instance + */ public void setAccountDao(AccountDao accountDao) { this.accountDao = accountDao; } + /** + * Authenticates a user by first attempting to resolve the account via email + * lookup, then delegating to the parent class for authentication against LDAP. + * + * @param authentication the authentication request object + * @return an authenticated {@link Authentication} instance if successful + * @throws AuthenticationException if authentication fails + */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { Assert.isInstanceOf(UsernamePasswordAuthenticationToken.class, authentication, () -> this.messages.getMessage("LdapAuthenticationProvider.onlySupports", "Only UsernamePasswordAuthenticationToken is supported")); + UsernamePasswordAuthenticationToken userToken = (UsernamePasswordAuthenticationToken) authentication; Account account = null; + try { account = accountDao.findByEmail(userToken.getName()); - } catch (DataServiceException e) { - } catch (NameNotFoundException e) { + } catch (DataServiceException | NameNotFoundException ignored) { + // Swallow exceptions and proceed with normal authentication if account is not + // found } + + // If an account was found, replace the authentication token with its UID if (account != null) { userToken = new UsernamePasswordAuthenticationToken(account.getUid(), userToken.getCredentials()); } @@ -56,7 +114,10 @@ public Authentication authenticate(Authentication authentication) throws Authent throw new BadCredentialsException( this.messages.getMessage("AbstractLdapAuthenticationProvider.emptyPassword", "Empty Password")); } + Assert.notNull(password, "Null password was supplied in authentication token"); + + // Perform LDAP authentication DirContextOperations userData = doAuthentication(userToken); UserDetails user = this.userDetailsContextMapper.mapUserFromContext(userData, username, loadUserAuthorities(userData, authentication.getName(), (String) authentication.getCredentials())); diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapConfig.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapConfig.java index eb65060f..0b7dd49c 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapConfig.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedLdapConfig.java @@ -16,7 +16,6 @@ * You should have received a copy of the GNU General Public License along with * geOrchestra. If not, see . */ - package org.georchestra.gateway.security.ldap.extended; import java.util.Optional; @@ -27,27 +26,93 @@ import lombok.NonNull; import lombok.Value; +/** + * Configuration properties for an extended LDAP authentication source. + *

+ * This class represents the settings required to connect and authenticate + * against an extended geOrchestra LDAP directory, including user and role + * search configurations, as well as optional administrator credentials. + *

+ * + *

+ * Extended LDAP configurations include additional organization-related fields + * used for enhanced user management. + *

+ */ @Value @Builder @Generated public class ExtendedLdapConfig { + + /** + * The unique identifier for this LDAP configuration. + */ private @NonNull String name; + + /** + * Flag indicating whether this LDAP configuration is enabled. + */ private boolean enabled; + + /** + * The LDAP server URL. + */ private @NonNull String url; + + /** + * The base distinguished name (DN) of the LDAP directory. + */ private @NonNull String baseDn; + /** + * The relative distinguished name (RDN) of the user entries. + */ private @NonNull String usersRdn; + + /** + * The search filter used to find user entries in the LDAP directory. + */ private @NonNull String usersSearchFilter; + + /** + * The relative distinguished name (RDN) of the role entries. + */ private @NonNull String rolesRdn; + + /** + * The search filter used to find role entries in the LDAP directory. + */ private @NonNull String rolesSearchFilter; - // null = all atts, empty == none + + /** + * The attributes to be retrieved for users. + *

+ * A {@code null} value indicates all attributes should be returned, while an + * empty array means no attributes will be returned. + *

+ */ private String[] returningAttributes; + /** + * Optional administrator distinguished name (DN) for performing privileged + * operations. + */ @Default private @NonNull Optional adminDn = Optional.empty(); + + /** + * Optional administrator password for privileged operations. + */ @Default private @NonNull Optional adminPassword = Optional.empty(); + /** + * The relative distinguished name (RDN) of the organization entries. + */ private @NonNull String orgsRdn; + + /** + * The relative distinguished name (RDN) of the pending organization entries. + */ private @NonNull String pendingOrgsRdn; -} \ No newline at end of file +} diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedPasswordPolicyAwareContextSource.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedPasswordPolicyAwareContextSource.java index 740ddbcd..e55f5f38 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedPasswordPolicyAwareContextSource.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/ExtendedPasswordPolicyAwareContextSource.java @@ -1,3 +1,21 @@ +/* + * Copyright (C) 2022 by the geOrchestra PSC + * + * This file is part of geOrchestra. + * + * geOrchestra is free software: you can redistribute it and/or modify it under + * the terms of the GNU General Public License as published by the Free + * Software Foundation, either version 3 of the License, or (at your option) + * any later version. + * + * geOrchestra is distributed in the hope that it will be useful, but WITHOUT + * ANY WARRANTY; without even the implied warranty of MERCHANTABILITY or + * FITNESS FOR A PARTICULAR PURPOSE. See the GNU General Public License for + * more details. + * + * You should have received a copy of the GNU General Public License along with + * geOrchestra. If not, see . + */ package org.georchestra.gateway.security.ldap.extended; import javax.naming.Context; @@ -13,37 +31,78 @@ import org.springframework.security.ldap.ppolicy.PasswordPolicyException; import org.springframework.security.ldap.ppolicy.PasswordPolicyResponseControl; +/** + * Extended version of {@link PasswordPolicyAwareContextSource} that enforces + * LDAP password policy controls when binding as a user. + *

+ * This implementation first binds as the manager user before attempting to + * rebind as the actual principal, allowing for password policy controls to be + * applied correctly. + *

+ * If the password policy control response indicates an error (e.g., password + * expired, account locked), a {@link PasswordPolicyException} is thrown. + */ public class ExtendedPasswordPolicyAwareContextSource extends PasswordPolicyAwareContextSource { + /** + * Constructs an {@code ExtendedPasswordPolicyAwareContextSource} with the given + * LDAP provider URL. + * + * @param providerUrl the LDAP provider URL + */ public ExtendedPasswordPolicyAwareContextSource(String providerUrl) { super(providerUrl); } + /** + * Obtains an LDAP {@link DirContext} for the specified principal and + * credentials, enforcing LDAP password policy controls. + *

+ * If binding as the configured manager user (admin DN), it delegates directly + * to {@link PasswordPolicyAwareContextSource#getContext(String, String)}. + * Otherwise, it first binds as the manager user before reconnecting as the + * specified principal to ensure password policy controls are properly applied. + * + * @param principal the distinguished name (DN) of the user to authenticate + * @param credentials the user's credentials + * @return a bound {@link DirContext} for the authenticated user + * @throws PasswordPolicyException if the LDAP server enforces password policy + * restrictions + */ @Override public DirContext getContext(String principal, String credentials) throws PasswordPolicyException { - if (principal.equals(this.userDn)) { + final String userdn = getUserDn(); + if (principal.equals(userdn)) { return super.getContext(principal, credentials); } - this.logger.trace(LogMessage.format("Binding as %s, prior to reconnect as user %s", this.userDn, principal)); - // First bind as manager user before rebinding as the specific principal. - LdapContext ctx = (LdapContext) super.getContext(this.userDn, this.password); + + this.logger.trace(LogMessage.format("Binding as %s, prior to reconnect as user %s", userdn, principal)); + + // Bind as the manager user before re-binding as the specific principal + LdapContext ctx = (LdapContext) super.getContext(userdn, getPassword()); Control[] rctls = { new PasswordPolicyControl(false) }; + try { ctx.addToEnvironment(Context.SECURITY_PRINCIPAL, principal); ctx.addToEnvironment(Context.SECURITY_CREDENTIALS, credentials); ctx.reconnect(rctls); } catch (javax.naming.NamingException ex) { PasswordPolicyResponseControl ctrl = PasswordPolicyControlExtractor.extractControl(ctx); + if (this.logger.isDebugEnabled()) { this.logger.debug(LogMessage.format("Failed to bind with %s", ctrl), ex); } + LdapUtils.closeContext(ctx); + if (ctrl != null && ctrl.getErrorStatus() != null) { throw new PasswordPolicyException(ctrl.getErrorStatus()); } + throw LdapUtils.convertLdapException(ex); } + this.logger.debug(LogMessage.of(() -> "Bound with " + PasswordPolicyControlExtractor.extractControl(ctx))); return ctx; } -} \ No newline at end of file +} diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticatedUserMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticatedUserMapper.java index 58dae5ff..4ed99043 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticatedUserMapper.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticatedUserMapper.java @@ -16,7 +16,6 @@ * You should have received a copy of the GNU General Public License along with * geOrchestra. If not, see . */ - package org.georchestra.gateway.security.ldap.extended; import java.util.ArrayList; @@ -37,14 +36,19 @@ import lombok.RequiredArgsConstructor; /** - * {@link GeorchestraUserMapperExtension} that maps LDAP-authenticated token to - * {@link GeorchestraUser} by calling {@link UsersApi#findByUsername(String)}, - * with the authentication token's principal name as argument. + * Maps LDAP-authenticated tokens to {@link GeorchestraUser} instances by + * retrieving user details from the configured {@link UsersApi}. + *

+ * This implementation specifically handles instances of + * {@link GeorchestraUserNamePasswordAuthenticationToken}, using its + * {@link GeorchestraUserNamePasswordAuthenticationToken#getConfigName() + * configuration name} to resolve users from the correct LDAP database. + *

*

- * Resolves only {@link GeorchestraUserNamePasswordAuthenticationToken}, using - * its {@link GeorchestraUserNamePasswordAuthenticationToken#getConfigName() - * configName} to disambiguate amongst different configured LDAP databases. - * + * Additionally, this class ensures role name consistency by normalizing + * mismatched prefixes between LDAP authorities and geOrchestra roles. + *

+ * * @see DemultiplexingUsersApi */ @RequiredArgsConstructor @@ -54,13 +58,19 @@ class GeorchestraLdapAuthenticatedUserMapper implements GeorchestraUserMapperExt @Override public Optional resolve(Authentication authToken) { - return Optional.ofNullable(authToken)// - .filter(GeorchestraUserNamePasswordAuthenticationToken.class::isInstance) - .map(GeorchestraUserNamePasswordAuthenticationToken.class::cast)// - .filter(token -> token.getPrincipal() instanceof LdapUserDetails)// - .flatMap(this::map); + return Optional.ofNullable(authToken).filter(GeorchestraUserNamePasswordAuthenticationToken.class::isInstance) + .map(GeorchestraUserNamePasswordAuthenticationToken.class::cast) + .filter(token -> token.getPrincipal() instanceof LdapUserDetails).flatMap(this::map); } + /** + * Retrieves user details from the appropriate LDAP database based on the + * authentication token's configuration name. + * + * @param token the LDAP authentication token + * @return an {@link Optional} containing the resolved {@link GeorchestraUser}, + * or empty if no user was found + */ Optional map(GeorchestraUserNamePasswordAuthenticationToken token) { final LdapUserDetails principal = (LdapUserDetails) token.getPrincipal(); final String ldapConfigName = token.getConfigName(); @@ -70,13 +80,23 @@ Optional map(GeorchestraUserNamePasswordAuthenticationToken tok return user.map(u -> fixPrefixedRoleNames(u, token)); } + /** + * Ensures that role names are properly prefixed with "ROLE_" for consistency + * between LDAP and geOrchestra role management. + *

+ * Also updates LDAP password expiration details in the user object. + *

+ * + * @param user the resolved user object + * @param token the authentication token containing authorities + * @return the updated {@link GeorchestraUser} with normalized roles + */ private GeorchestraUser fixPrefixedRoleNames(GeorchestraUser user, GeorchestraUserNamePasswordAuthenticationToken token) { final LdapUserDetailsImpl principal = (LdapUserDetailsImpl) token.getPrincipal(); - // Fix role name mismatch between authority provider (adds ROLE_ prefix) and - // users api + // Ensure consistent role naming by normalizing both authorities and user roles Stream authorityRoleNames = token.getAuthorities().stream() .filter(SimpleGrantedAuthority.class::isInstance).map(GrantedAuthority::getAuthority) .map(this::normalize); @@ -84,7 +104,9 @@ private GeorchestraUser fixPrefixedRoleNames(GeorchestraUser user, Stream userRoles = user.getRoles().stream().map(this::normalize); List roles = Stream.concat(authorityRoleNames, userRoles).distinct().toList(); - user.setRoles(new ArrayList<>(roles));// mutable + user.setRoles(new ArrayList<>(roles)); + + // Set LDAP password expiration warnings if applicable if (principal.getTimeBeforeExpiration() < Integer.MAX_VALUE) { user.setLdapWarn(true); user.setLdapRemainingDays(String.valueOf(principal.getTimeBeforeExpiration() / (60 * 60 * 24))); @@ -95,6 +117,12 @@ private GeorchestraUser fixPrefixedRoleNames(GeorchestraUser user, return user; } + /** + * Normalizes role names by ensuring they start with "ROLE_". + * + * @param role the original role name + * @return the normalized role name + */ private String normalize(String role) { return role.startsWith("ROLE_") ? role : "ROLE_" + role; } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticationProvider.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticationProvider.java index 61457575..2a0b9001 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticationProvider.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraLdapAuthenticationProvider.java @@ -16,7 +16,6 @@ * You should have received a copy of the GNU General Public License along with * geOrchestra. If not, see . */ - package org.georchestra.gateway.security.ldap.extended; import org.georchestra.gateway.security.ldap.AuthenticationProviderDecorator; @@ -27,16 +26,59 @@ import lombok.NonNull; import lombok.extern.slf4j.Slf4j; +/** + * LDAP authentication provider for geOrchestra with extended user management + * capabilities. + *

+ * This provider wraps an existing {@link AuthenticationProvider} and enhances + * it with additional behavior specific to geOrchestra. It ensures that + * authenticated users are associated with the correct LDAP configuration and + * returns a {@link GeorchestraUserNamePasswordAuthenticationToken} upon + * successful authentication. + *

+ * + *

+ * Performance Consideration: + *

+ *

+ * Under heavy load, the LDAP server may be overwhelmed and start failing + * authentication requests. To mitigate this, a Caffeine cache with a short TTL + * could be introduced to handle concurrency and avoid redundant authentication + * attempts when multiple requests arrive simultaneously. + *

+ */ @Slf4j(topic = "org.georchestra.gateway.security.ldap.extended") public class GeorchestraLdapAuthenticationProvider extends AuthenticationProviderDecorator { private final @NonNull String configName; + /** + * Constructs a new {@code GeorchestraLdapAuthenticationProvider} that wraps a + * delegate authentication provider. + * + * @param configName the name of the LDAP configuration associated with this + * provider + * @param delegate the actual LDAP authentication provider being decorated + */ public GeorchestraLdapAuthenticationProvider(@NonNull String configName, @NonNull AuthenticationProvider delegate) { super(delegate); this.configName = configName; } + /** + * Attempts to authenticate a user against the configured LDAP authentication + * provider. + *

+ * If authentication succeeds, it wraps the authentication result in a + * {@link GeorchestraUserNamePasswordAuthenticationToken}, ensuring that the + * user's authentication is correctly associated with the configured LDAP + * instance. + *

+ * + * @param authentication the authentication request object + * @return the authenticated token, or {@code null} if authentication fails + * @throws AuthenticationException if authentication is unsuccessful + */ @Override public Authentication authenticate(Authentication authentication) throws AuthenticationException { log.debug("Attempting to authenticate user {} against {} extended LDAP", authentication.getName(), configName); @@ -55,5 +97,4 @@ public Authentication authenticate(Authentication authentication) throws Authent throw e; } } - -} \ No newline at end of file +} diff --git a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraUserNamePasswordAuthenticationToken.java b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraUserNamePasswordAuthenticationToken.java index 28d8220b..504031a4 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraUserNamePasswordAuthenticationToken.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/ldap/extended/GeorchestraUserNamePasswordAuthenticationToken.java @@ -16,7 +16,6 @@ * You should have received a copy of the GNU General Public License along with * geOrchestra. If not, see . */ - package org.georchestra.gateway.security.ldap.extended; import java.util.Collection; @@ -30,48 +29,105 @@ import lombok.RequiredArgsConstructor; /** - * A specialized {@link Authentication} object for Georchestra extensions aware - * LDAP databases, such as the default OpenLDAP schema, where {@link UsersApi} - * can be used to fetch additional user identity information. + * A specialized {@link Authentication} implementation for Georchestra that + * associates authentication details with a specific LDAP configuration. + *

+ * This class is designed for use with Georchestra-aware LDAP databases, such as + * the default OpenLDAP schema, where {@link UsersApi} can be used to fetch + * additional user identity information. + *

+ *

+ * It acts as a wrapper around an existing {@link Authentication} instance, + * ensuring that authentication context remains associated with the correct LDAP + * configuration. + *

*/ @RequiredArgsConstructor public class GeorchestraUserNamePasswordAuthenticationToken implements Authentication { private static final long serialVersionUID = 1L; + /** + * The name of the LDAP configuration associated with this authentication. + */ private final @NonNull @Getter String configName; + + /** + * The original authentication instance being wrapped. + */ private final @NonNull Authentication orig; + /** + * Returns the name of the authenticated principal. + * + * @return the authenticated user's name + */ @Override public String getName() { return orig.getName(); } + /** + * Returns the authorities granted to the authenticated user. + * + * @return a collection of granted authorities + */ @Override public Collection getAuthorities() { return orig.getAuthorities(); } + /** + * Returns the credentials (e.g., password) used for authentication. + *

+ * This method always returns {@code null} as passwords should not be stored or + * exposed beyond the authentication process. + *

+ * + * @return {@code null} (credentials are not stored) + */ @Override public Object getCredentials() { return null; } + /** + * Returns additional details about the authenticated request. + * + * @return the authentication details object + */ @Override public Object getDetails() { return orig.getDetails(); } + /** + * Returns the authenticated principal, typically a user object or username. + * + * @return the authenticated principal + */ @Override public Object getPrincipal() { return orig.getPrincipal(); } + /** + * Indicates whether the authentication is currently valid. + * + * @return {@code true} if authenticated, otherwise {@code false} + */ @Override public boolean isAuthenticated() { return orig.isAuthenticated(); } + /** + * Sets the authentication status of the user. + * + * @param isAuthenticated {@code true} to mark as authenticated, {@code false} + * otherwise + * @throws IllegalArgumentException if setting authentication is not supported + */ @Override public void setAuthenticated(boolean isAuthenticated) throws IllegalArgumentException { orig.setAuthenticated(isAuthenticated); diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/ExtendedOAuth2ClientProperties.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/ExtendedOAuth2ClientProperties.java index 70790ee0..a06d43a2 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/ExtendedOAuth2ClientProperties.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/ExtendedOAuth2ClientProperties.java @@ -21,32 +21,91 @@ import java.util.HashMap; import java.util.Map; -import org.springframework.beans.factory.InitializingBean; import org.springframework.boot.autoconfigure.security.oauth2.client.OAuth2ClientProperties; import org.springframework.boot.context.properties.ConfigurationProperties; +/** + * Extended version of {@link OAuth2ClientProperties} to support additional + * OAuth2 provider configurations, specifically adding an end session URI for + * logout handling. + *

+ * This class allows defining extra properties under the + * `spring.security.oauth2.client` namespace, enabling seamless integration with + * OAuth2 providers that support session termination via a dedicated logout + * endpoint. + *

+ * + *

+ * Example configuration: + *

+ * + *
+ * {@code
+ * spring:
+ *   security:
+ *     oauth2:
+ *       client:
+ *         provider:
+ *           keycloak:
+ *             issuer-uri: https://keycloak.example.com/realms/myrealm
+ *             authorization-uri: https://keycloak.example.com/auth
+ *             token-uri: https://keycloak.example.com/token
+ *             end-session-uri: https://keycloak.example.com/logout
+ * }
+ * 
+ * + *

+ * This allows retrieving the end session URI via: + *

+ * + *
+ * {
+ *     @code
+ *     String logoutUrl = extendedOAuth2ClientProperties.getProvider().get("keycloak").getEndSessionUri();
+ * }
+ * 
+ * + * @see OAuth2ClientProperties + */ @ConfigurationProperties(prefix = "spring.security.oauth2.client") -public class ExtendedOAuth2ClientProperties implements InitializingBean { +public class ExtendedOAuth2ClientProperties { private final Map provider = new HashMap<>(); + /** + * Retrieves the map of configured OAuth2 providers. + * + * @return a map where the key is the provider name, and the value contains its + * configuration. + */ public Map getProvider() { return this.provider; } + /** + * Represents an extended OAuth2 provider configuration, adding support for an + * end session URI to handle provider-specific logout functionality. + */ public static class Provider extends OAuth2ClientProperties.Provider { + private String endSessionUri; + /** + * Retrieves the provider's end session URI, used for logging out the user. + * + * @return the end session URI, or {@code null} if not configured. + */ public String getEndSessionUri() { return this.endSessionUri; } + /** + * Sets the provider's end session URI. + * + * @param endSessionUri the logout endpoint of the OAuth2 provider. + */ public void setEndSessionUri(String endSessionUri) { this.endSessionUri = endSessionUri; } } - - @Override - public void afterPropertiesSet() { - } } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2Configuration.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2Configuration.java index 491bd377..df1f99e4 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2Configuration.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2Configuration.java @@ -38,9 +38,7 @@ import org.springframework.context.annotation.Configuration; import org.springframework.context.annotation.Profile; import org.springframework.http.client.reactive.ReactorClientHttpConnector; -import org.springframework.security.authentication.ReactiveAuthenticationManager; import org.springframework.security.config.web.server.ServerHttpSecurity; -import org.springframework.security.config.web.server.ServerHttpSecurity.OAuth2LoginSpec; import org.springframework.security.oauth2.client.endpoint.OAuth2AuthorizationCodeGrantRequest; import org.springframework.security.oauth2.client.endpoint.ReactiveOAuth2AccessTokenResponseClient; import org.springframework.security.oauth2.client.endpoint.WebClientReactiveAuthorizationCodeTokenResponseClient; @@ -64,6 +62,13 @@ import reactor.netty.http.client.HttpClient; import reactor.netty.transport.ProxyProvider; +/** + * OAuth2 security configuration for geOrchestra's Gateway. + *

+ * This configuration enables OAuth2 authentication, OpenID Connect integration, + * and HTTP proxy support for OAuth2 clients. It includes support for OAuth2 + * login, JWT decoding, role mapping, and customized logout handling. + */ @Configuration(proxyBeanMethods = false) @EnableConfigurationProperties({ OAuth2ProxyConfigProperties.class, OpenIdConnectCustomClaimsConfigProperties.class, GeorchestraGatewaySecurityConfigProperties.class, ExtendedOAuth2ClientProperties.class }) @@ -72,14 +77,28 @@ public class OAuth2Configuration { private @Value("${georchestra.gateway.logoutUrl:/?logout}") String georchestraLogoutUrl; + /** + * Customizer for enabling OAuth2 authentication in the Spring Security filter + * chain. + */ public static final class OAuth2AuthenticationCustomizer implements ServerHttpSecurityCustomizer { - - public @Override void customize(ServerHttpSecurity http) { + @Override + public void customize(ServerHttpSecurity http) { log.info("Enabling authentication support using an OAuth 2.0 and/or OpenID Connect 1.0 Provider"); http.oauth2Login(); } } + /** + * Configures the OIDC logout handler to handle end-session requests properly. + * + * @param clientRegistrationRepository The repository of registered OAuth2 + * clients. + * @param properties The extended OAuth2 client properties + * including logout endpoints. + * @return A configured {@link ServerLogoutSuccessHandler} that initiates OIDC + * logout. + */ @Bean @Profile("!test") ServerLogoutSuccessHandler oidcLogoutSuccessHandler( @@ -100,23 +119,41 @@ ServerLogoutSuccessHandler oidcLogoutSuccessHandler( } }); - OidcClientInitiatedServerLogoutSuccessHandler oidcLogoutSuccessHandler = new OidcClientInitiatedServerLogoutSuccessHandler( + OidcClientInitiatedServerLogoutSuccessHandler logoutHandler = new OidcClientInitiatedServerLogoutSuccessHandler( clientRegistrationRepository); - oidcLogoutSuccessHandler.setPostLogoutRedirectUri("{baseUrl}/login?logout"); - oidcLogoutSuccessHandler.setLogoutSuccessUrl(URI.create(georchestraLogoutUrl)); - return oidcLogoutSuccessHandler; + logoutHandler.setPostLogoutRedirectUri("{baseUrl}/login?logout"); + logoutHandler.setLogoutSuccessUrl(URI.create(georchestraLogoutUrl)); + return logoutHandler; } + /** + * Registers a Spring Security customizer to enable OAuth2 login. + * + * @return A {@link ServerHttpSecurityCustomizer} instance. + */ @Bean ServerHttpSecurityCustomizer oauth2LoginEnablingCustomizer() { return new OAuth2AuthenticationCustomizer(); } + /** + * Provides a default OAuth2 user mapper for mapping authentication tokens to + * geOrchestra users. + * + * @return An instance of {@link OAuth2UserMapper}. + */ @Bean OAuth2UserMapper oAuth2GeorchestraUserUserMapper() { return new OAuth2UserMapper(); } + /** + * Provides a custom OpenID Connect user mapper for processing non-standard + * claims. + * + * @param nonStandardClaimsConfig Configuration for custom OIDC claims. + * @return An instance of {@link OpenIdConnectUserMapper}. + */ @Bean OpenIdConnectUserMapper openIdConnectGeorchestraUserUserMapper( OpenIdConnectCustomClaimsConfigProperties nonStandardClaimsConfig) { @@ -124,31 +161,28 @@ OpenIdConnectUserMapper openIdConnectGeorchestraUserUserMapper( } /** - * Configures the OAuth2 client to use the HTTP proxy if enabled, by means of - * {@linkplain #oauth2WebClient} - *

- * {@link OAuth2LoginSpec ServerHttpSecurity$OAuth2LoginSpec#createDefault()} - * will return a {@link ReactiveAuthenticationManager} by first looking up a - * {@link ReactiveOAuth2AccessTokenResponseClient - * ReactiveOAuth2AccessTokenResponseClient} - * in the application context, and creating a default one if none is found. - *

- * We provide such bean here to have it configured with an {@link WebClient HTTP - * client} that will use the {@link OAuth2ProxyConfigProperties configured} HTTP - * proxy. + * Configures the OAuth2 access token response client to support an HTTP proxy + * if enabled. + * + * @param oauth2WebClient The WebClient configured for OAuth2 communication. + * @return A configured instance of + * {@link ReactiveOAuth2AccessTokenResponseClient}. */ @Bean ReactiveOAuth2AccessTokenResponseClient reactiveOAuth2AccessTokenResponseClient( @Qualifier("oauth2WebClient") WebClient oauth2WebClient) { - WebClientReactiveAuthorizationCodeTokenResponseClient client = new WebClientReactiveAuthorizationCodeTokenResponseClient(); client.setWebClient(oauth2WebClient); return client; } /** - * Custom JWT decoder factory to use the web client that can be set up to go - * through an HTTP proxy + * Creates a JWT decoder factory that supports OAuth2 authentication and an + * optional HTTP proxy. + * + * @param oauth2WebClient The WebClient used to fetch JWT keys if needed. + * @return A {@link ReactiveJwtDecoderFactory} configured for OAuth2 + * authentication. */ @Bean ReactiveJwtDecoderFactory idTokenDecoderFactory( @@ -174,18 +208,31 @@ ReactiveJwtDecoderFactory idTokenDecoderFactory( return jwtDecoder.decode(token).map(jwt -> new Jwt(jwt.getTokenValue(), jwt.getIssuedAt(), jwt.getExpiresAt(), jwt.getHeaders(), removeNullClaims(jwt.getClaims()))); } catch (ParseException exception) { - throw new BadJwtException( - "An error occurred while attempting to decode the Jwt: " + exception.getMessage(), exception); + throw new BadJwtException("Failed to decode the JWT token", exception); } }; } - // Some IDPs return claims with null value but Spring does not handle them + /** + * Removes null claims from JWT tokens to avoid Spring OAuth2 processing issues. + */ private Map removeNullClaims(Map claims) { return claims.entrySet().stream().filter(entry -> entry.getValue() != null) .collect(Collectors.toMap(Map.Entry::getKey, Map.Entry::getValue)); } + /** + * Provides a default implementation of {@link DefaultReactiveOAuth2UserService} + * for handling OAuth2 user authentication. + *

+ * This service is responsible for retrieving user details from the OAuth2 + * provider and processing user information. The configured {@link WebClient} is + * used to make requests to the provider's user info endpoint, allowing it to + * support an HTTP proxy if configured. + * + * @param oauth2WebClient The WebClient instance configured for OAuth2 requests. + * @return A configured instance of {@link DefaultReactiveOAuth2UserService}. + */ @Bean DefaultReactiveOAuth2UserService reactiveOAuth2UserService( @Qualifier("oauth2WebClient") WebClient oauth2WebClient) { @@ -195,6 +242,18 @@ DefaultReactiveOAuth2UserService reactiveOAuth2UserService( return service; } + /** + * Provides a customized {@link OidcReactiveOAuth2UserService} for handling + * OpenID Connect (OIDC) authentication. + *

+ * This service extends the default OAuth2 user service to support OIDC-specific + * claims and processing. It delegates OAuth2 authentication to the provided + * {@link DefaultReactiveOAuth2UserService}. + * + * @param oauth2Delegate The default OAuth2 user service used for retrieving + * user information. + * @return A configured instance of {@link OidcReactiveOAuth2UserService}. + */ @Bean OidcReactiveOAuth2UserService oidcReactiveOAuth2UserService(DefaultReactiveOAuth2UserService oauth2Delegate) { OidcReactiveOAuth2UserService oidUserService = new OidcReactiveOAuth2UserService(); @@ -203,39 +262,24 @@ OidcReactiveOAuth2UserService oidcReactiveOAuth2UserService(DefaultReactiveOAuth } /** - * {@link WebClient} to use when performing HTTP POST requests to the OAuth2 - * service providers, that can be configured to use an HTTP proxy through the - * {@link OAuth2ProxyConfigProperties} configuration properties. + * Configures a WebClient for OAuth2 authentication requests, supporting HTTP + * proxy settings if enabled. * - * @param proxyConfig defines the HTTP proxy settings specific for the OAuth2 - * client. If not - * {@link OAuth2ProxyConfigProperties#isEnabled() enabled}, - * the {@code WebClient} will use the proxy configured - * through System properties ({@literal http(s).proxyHost} - * and {@literal http(s).proxyPort}), if any. + * @param proxyConfig The proxy configuration properties. + * @return A configured {@link WebClient} instance. */ @Bean("oauth2WebClient") WebClient oauth2WebClient(OAuth2ProxyConfigProperties proxyConfig) { - final String proxyHost = proxyConfig.getHost(); - final Integer proxyPort = proxyConfig.getPort(); - final String proxyUser = proxyConfig.getUsername(); - final String proxyPassword = proxyConfig.getPassword(); - HttpClient httpClient = HttpClient.create(); if (proxyConfig.isEnabled()) { - if (proxyHost == null || proxyPort == null) { - throw new IllegalStateException("OAuth2 client HTTP proxy is enabled, but host and port not provided"); - } - log.info("Oauth2 client will use HTTP proxy {}:{}", proxyHost, proxyPort); - httpClient = httpClient.proxy(proxy -> proxy.type(ProxyProvider.Proxy.HTTP).host(proxyHost).port(proxyPort) - .username(proxyUser).password(user -> proxyPassword)); + log.info("OAuth2 client will use HTTP proxy {}:{}", proxyConfig.getHost(), proxyConfig.getPort()); + httpClient = httpClient.proxy(proxy -> proxy.type(ProxyProvider.Proxy.HTTP).host(proxyConfig.getHost()) + .port(proxyConfig.getPort()).username(proxyConfig.getUsername()) + .password(user -> proxyConfig.getPassword())); } else { - log.info("Oauth2 client will use HTTP proxy from System properties if provided"); + log.info("OAuth2 client will use system-defined HTTP proxy settings if available."); httpClient = httpClient.proxyWithSystemProperties(); } - ReactorClientHttpConnector conn = new ReactorClientHttpConnector(httpClient); - - return WebClient.builder().clientConnector(conn).build(); + return WebClient.builder().clientConnector(new ReactorClientHttpConnector(httpClient)).build(); } - } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2ProxyConfigProperties.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2ProxyConfigProperties.java index b8c57132..a3317ea4 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2ProxyConfigProperties.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2ProxyConfigProperties.java @@ -22,11 +22,59 @@ import lombok.Data; +/** + * Configuration properties for the OAuth2 client HTTP proxy. + *

+ * This configuration allows the OAuth2 client to use a proxy when communicating + * with the authentication provider, which can be useful in environments with + * restricted network access. + *

+ * + *

+ * Example configuration in {@code application.yml}: + *

+ * + *
+ * 
+ * georchestra:
+ *   gateway:
+ *     security:
+ *       oauth2:
+ *         proxy:
+ *           enabled: true
+ *           host: proxy.example.com
+ *           port: 8080
+ *           username: proxyuser
+ *           password: proxypass
+ * 
+ * 
+ */ @ConfigurationProperties(prefix = "georchestra.gateway.security.oauth2.proxy") -public @Data class OAuth2ProxyConfigProperties { +@Data +public class OAuth2ProxyConfigProperties { + + /** + * Whether the OAuth2 client should use an HTTP proxy. + */ private boolean enabled; + + /** + * The proxy host address (e.g., {@code proxy.example.com}). + */ private String host; + + /** + * The proxy port number. + */ private Integer port; + + /** + * The optional proxy username for authentication. + */ private String username; + + /** + * The optional proxy password for authentication. + */ private String password; } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2UserMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2UserMapper.java index a8ae3e79..5fdf56d9 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2UserMapper.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OAuth2UserMapper.java @@ -16,7 +16,6 @@ * You should have received a copy of the GNU General Public License along with * geOrchestra. If not, see . */ - package org.georchestra.gateway.security.oauth2; import java.util.ArrayList; @@ -40,38 +39,55 @@ /** * Maps {@link OAuth2AuthenticationToken} to {@link GeorchestraUser}. *

+ * This class extracts user information from an OAuth2 authentication token and + * maps it to a {@link GeorchestraUser}. The mapping process follows these + * rules: + *

*
    - *
  • The {@link OAuth2User principal}'s {@literal login} - * {@link OAuth2User#getAttributes() attribute} is used with preference to the - * {@link OAuth2AuthenticationToken#getName() name} if provided, to set the - * {@link GeorchestraUser#setUsername(String) username}, since the name is - * usually an external sytem's numeric identifier that's not really appropriate - * for a username. - *
  • The user's {@link GeorchestraUser#setEmail(String) email} is obtained - * from the {@literal email} {@link OAuth2User#getAttributes() attribute}, if - * present. - *
  • The user's {@link GeorchestraUser#setRoles(List) roles} are derived from - * the {@link GrantedAuthority granted authorities} in the - * {@link OAuth2User#getAuthorities()}, removing those that start with - * {@literal ROLE_SCOPE_} or {@code SCOPE_}. + *
  • The {@link OAuth2User principal}'s {@code login} attribute is used with + * preference over {@link OAuth2AuthenticationToken#getName()} if available, as + * the latter often contains an external system's numeric identifier.
  • + *
  • The user's email is extracted from the {@code email} attribute, if + * present.
  • + *
  • Roles are derived from the granted authorities but exclude any that start + * with {@code ROLE_SCOPE_} or {@code SCOPE_}.
  • *
*/ @Slf4j(topic = "org.georchestra.gateway.security.oauth2") public class OAuth2UserMapper implements GeorchestraUserMapperExtension { + /** + * Attempts to resolve an OAuth2 authentication token into a + * {@link GeorchestraUser}. + * + * @param authToken The authentication token to resolve. + * @return An {@link Optional} containing the mapped user if the token is valid, + * or {@link Optional#empty()} if it cannot be mapped. + */ @Override public Optional resolve(Authentication authToken) { - return Optional.ofNullable(authToken)// - .filter(OAuth2AuthenticationToken.class::isInstance)// - .map(OAuth2AuthenticationToken.class::cast)// - .filter(tokenFilter())// - .flatMap(this::map); + return Optional.ofNullable(authToken).filter(OAuth2AuthenticationToken.class::isInstance) + .map(OAuth2AuthenticationToken.class::cast).filter(tokenFilter()).flatMap(this::map); } + /** + * Provides a predicate to filter which OAuth2 tokens should be processed. + *

+ * The default implementation accepts all tokens. + *

+ * + * @return A {@link Predicate} for filtering authentication tokens. + */ protected Predicate tokenFilter() { return token -> true; } + /** + * Maps an {@link OAuth2AuthenticationToken} to a {@link GeorchestraUser}. + * + * @param token The OAuth2 authentication token. + * @return An {@link Optional} containing the mapped {@link GeorchestraUser}. + */ protected Optional map(OAuth2AuthenticationToken token) { logger().debug("Mapping {} authentication token from provider {}", token.getPrincipal().getClass().getSimpleName(), token.getAuthorizedClientRegistrationId()); @@ -82,23 +98,29 @@ protected Optional map(OAuth2AuthenticationToken token) { user.setOAuth2Uid(token.getName()); Map attributes = oAuth2User.getAttributes(); - List roles = resolveRoles(oAuth2User.getAuthorities()); String userName = token.getName(); String login = (String) attributes.get("login"); /* - * plain Oauth2 authentication user names are usually a number. The 'login' - * attribute usually carries over a more meaningful name, so use it in - * preference of userName if provided + * Plain OAuth2 authentication user names are often numeric identifiers. The + * 'login' attribute typically contains a more meaningful name, so it is used in + * preference to the username if available. */ apply(user::setUsername, login, userName); apply(user::setEmail, (String) attributes.get("email")); - user.setRoles(new ArrayList<>(roles));// mutable + user.setRoles(new ArrayList<>(roles)); // mutable return Optional.of(user); } + /** + * Resolves roles from granted authorities while excluding OAuth2 scope-related + * authorities. + * + * @param authorities The collection of granted authorities. + * @return A list of resolved role names. + */ protected List resolveRoles(Collection authorities) { return authorities.stream().map(GrantedAuthority::getAuthority).filter(scope -> { if (scope.startsWith("ROLE_SCOPE_") || scope.startsWith("SCOPE_")) { @@ -109,15 +131,26 @@ protected List resolveRoles(Collection autho }).toList(); } + /** + * Applies the first non-null candidate value to the specified setter function. + * + * @param setter The setter function to apply. + * @param candidates A varargs list of candidate values. + */ protected void apply(Consumer setter, String... candidates) { for (String candidateValue : candidates) { - if (null != candidateValue) { + if (candidateValue != null) { setter.accept(candidateValue); break; } } } + /** + * Provides access to the class logger. + * + * @return The logger instance. + */ protected Logger logger() { return log; } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectCustomClaimsConfigProperties.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectCustomClaimsConfigProperties.java index 22346d33..741dd4e7 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectCustomClaimsConfigProperties.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectCustomClaimsConfigProperties.java @@ -39,6 +39,14 @@ import lombok.experimental.Accessors; import lombok.extern.slf4j.Slf4j; +/** + * Configuration properties for extracting custom OpenID Connect (OIDC) claims. + *

+ * This class allows configuring how user information such as ID, roles, and + * organization details are extracted from an OAuth2/OIDC authentication token + * using JSONPath expressions. + *

+ */ @ConfigurationProperties(prefix = "georchestra.gateway.security.oidc.claims") @Slf4j(topic = "org.georchestra.gateway.security.oauth2") public @Data class OpenIdConnectCustomClaimsConfigProperties { @@ -47,50 +55,90 @@ private RolesMapping roles = new RolesMapping(); private JsonPathExtractor organization = new JsonPathExtractor(); + /** + * Retrieves the JSONPath extractor configuration for extracting the user ID. + * + * @return an {@link Optional} containing the {@link JsonPathExtractor} for ID + * extraction. + */ public Optional id() { return Optional.ofNullable(id); } + /** + * Retrieves the configuration for role mapping. + * + * @return an {@link Optional} containing the {@link RolesMapping} + * configuration. + */ public Optional roles() { return Optional.ofNullable(roles); } + /** + * Retrieves the JSONPath extractor configuration for extracting the + * organization. + * + * @return an {@link Optional} containing the {@link JsonPathExtractor} for + * organization extraction. + */ public Optional organization() { return Optional.ofNullable(organization); } + /** + * Configuration for extracting roles from OIDC claims. + *

+ * This class defines transformation rules for role extraction, including case + * formatting, normalization, and whether extracted roles should replace or + * append to existing ones. + *

+ */ @Accessors(chain = true) public static @Data class RolesMapping { private JsonPathExtractor json = new JsonPathExtractor(); /** - * Whether to return mapped role names as upper-case + * Whether to return mapped role names in uppercase. */ private boolean uppercase = true; /** - * Whether to remove special characters and replace spaces by underscores + * Whether to normalize role names by removing special characters and replacing + * spaces with underscores. */ private boolean normalize = true; /** - * Whether to append the resolved role names to the roles given by the OAuth2 - * authentication (true), or replace them (false). + * Whether to append the extracted roles to existing roles (true), or replace + * them (false). */ private boolean append = true; + /** + * Retrieves the JSONPath extractor for roles. + * + * @return an {@link Optional} containing the {@link JsonPathExtractor} for role + * extraction. + */ public Optional json() { return Optional.ofNullable(json); } + /** + * Extracts and applies roles from the provided claims to the given user. + * + * @param claims The OIDC claims from which roles should be extracted. + * @param target The {@link GeorchestraUser} to which the roles should be + * applied. + */ public void apply(Map claims, GeorchestraUser target) { - json().ifPresent(oidcClaimsConfig -> { List rawValues = oidcClaimsConfig.extract(claims); - List oidcRoles = rawValues.stream().map(this::applyTransforms) - // make sure the resulting list is mutable, Stream.toList() is not - .toList(); + List oidcRoles = rawValues.stream().map(this::applyTransforms).toList(); // Ensure the resulting + // list is mutable + if (oidcRoles.isEmpty()) { return; } @@ -101,6 +149,12 @@ public void apply(Map claims, GeorchestraUser target) { }); } + /** + * Applies configured transformations to a role value. + * + * @param value The original role value. + * @return The transformed role value. + */ private String applyTransforms(String value) { String result = uppercase ? value.toUpperCase() : value; if (normalize) { @@ -109,32 +163,44 @@ private String applyTransforms(String value) { return result; } + /** + * Normalizes a role string by: + *
    + *
  • Applying Unicode Normalization (NFC form).
  • + *
  • Removing diacritical marks (accents).
  • + *
  • Replacing whitespace with underscores.
  • + *
  • Removing special characters.
  • + *
+ * + * @param value The original role string. + * @return The normalized role string. + */ public String normalize(@NonNull String value) { - // apply Unicode Normalization (NFC: a + ◌̂ = â) (see - // https://www.unicode.org/reports/tr15/) + // Apply Unicode Normalization (NFC: a + ◌̂ = â) String normalized = Normalizer.normalize(value, Form.NFC); - // remove unicode accents and diacritics + // Remove diacritical marks normalized = normalized.replaceAll("\\p{InCombiningDiacriticalMarks}+", ""); - // replace all whitespace groups by a single underscore - normalized = value.replaceAll("\\s+", "_"); + // Replace all whitespace with underscores + normalized = normalized.replaceAll("\\s+", "_"); - // remove remaining characters like parenthesis, commas, etc - normalized = normalized.replaceAll("[^a-zA-Z0-9_]", ""); - return normalized; + // Remove remaining special characters + return normalized.replaceAll("[^a-zA-Z0-9_]", ""); } } + /** + * Extracts values from OIDC claims using JSONPath expressions. + */ @Accessors(chain = true) public static @Data class JsonPathExtractor { + /** - * JsonPath expression(s) to extract the role names from the - * {@literal Map} containing all OIDC authentication token - * claims. + * List of JSONPath expressions to extract values from OIDC claims. *

- * For example, if the claims map contains a JSON object under the - * {@literal groups_json} key with the value + * Example: + *

* *
          * {@code
@@ -146,31 +212,39 @@ public String normalize(@NonNull String value) {
          *         "parameter": []
          *       }
          *     ]
-         *   ]
+         * ]
          * }
          * 
* - * The JsonPath expression {@literal $.groups_json[0][0].name} would match only - * the first group name, while the expression {@literal $.groups_json..['name']} - * would match them all to a {@code List}. + * The JSONPath expression `$.groups_json[0][0].name` extracts the first group + * name, while `$.groups_json..['name']` extracts all group names into a list. */ private List path = new ArrayList<>(); /** - * @param claims - * @return + * Extracts values from the provided OIDC claims using the configured JSONPath + * expressions. + * + * @param claims The OIDC claims map. + * @return A list of extracted values. */ public @NonNull List extract(@NonNull Map claims) { - return this.path.stream()// - .map(jsonPathExpression -> this.extract(jsonPathExpression, claims))// - .flatMap(List::stream)// - .toList(); + return this.path.stream().map(jsonPathExpression -> this.extract(jsonPathExpression, claims)) + .flatMap(List::stream).toList(); } + /** + * Extracts values from the given claims using a single JSONPath expression. + * + * @param jsonPathExpression The JSONPath expression. + * @param claims The claims map. + * @return A list of extracted values. + */ private List extract(final String jsonPathExpression, Map claims) { if (!StringUtils.hasText(jsonPathExpression)) { return List.of(); } + // if we call claims.get(key) and the result is a JSON object, // the json api used is a shaded version of org.json at package // com.nimbusds.jose.shaded.json, we don't want to use that @@ -180,28 +254,32 @@ private List extract(final String jsonPathExpression, Map list = (matched instanceof List) ? (List) matched : List.of(matched); - return IntStream.range(0, list.size())// - .mapToObj(list::get)// - .filter(Objects::nonNull)// - .map(value -> validateValueIsString(jsonPathExpression, value))// - .toList(); + return IntStream.range(0, list.size()).mapToObj(list::get).filter(Objects::nonNull) + .map(value -> validateValueIsString(jsonPathExpression, value)).toList(); } - private String validateValueIsString(final String jsonPathExpression, @NonNull Object v) { - if (v instanceof String val) + /** + * Ensures that extracted values are of type {@link String}. + * + * @param jsonPathExpression The JSONPath expression used. + * @param value The extracted value. + * @return The extracted value as a string. + * @throws IllegalStateException If the extracted value is not a string. + */ + private String validateValueIsString(final String jsonPathExpression, @NonNull Object value) { + if (value instanceof String val) { return val; - - String msg = String.format("The JSONPath expression %s evaluates to %s instead of String. Value: %s", - jsonPathExpression, v.getClass().getCanonicalName(), v); - throw new IllegalStateException(msg); - + } + throw new IllegalStateException(String.format( + "The JSONPath expression %s evaluates to %s instead of String. Value: %s", + jsonPathExpression, value.getClass().getCanonicalName(), value)); } } -} +} \ No newline at end of file diff --git a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectUserMapper.java b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectUserMapper.java index 0b8c4397..6b3da0e6 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectUserMapper.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/oauth2/OpenIdConnectUserMapper.java @@ -16,7 +16,6 @@ * You should have received a copy of the GNU General Public License along with * geOrchestra. If not, see . */ - package org.georchestra.gateway.security.oauth2; import java.util.List; @@ -46,88 +45,83 @@ import lombok.extern.slf4j.Slf4j; /** - * Maps an OpenID authenticated {@link OidcUser user} to a + * Maps an OpenID Connect (OIDC) authenticated {@link OidcUser} to a * {@link GeorchestraUser}. *

- * {@link StandardClaimAccessor standard claims} map as follow: + * The mapping follows OpenID Connect standard claims: *

    - *
  • {@link StandardClaimAccessor#getSubject() subject} to - * {@link GeorchestraUser#getId() id} - *
  • {@link StandardClaimAccessor#getPreferredUsername preferredUsername} or - * {@link StandardClaimAccessor#getEmail email} to - * {@link GeorchestraUser#setUsername username}, in that order of precedence. - *
  • {@link StandardClaimAccessor#getGivenName givenName} to - * {@link GeorchestraUser#setFirstName firstName} - *
  • {@link StandardClaimAccessor#getEmail email} to - * {@link GeorchestraUser#setEmail email} - *
  • {@link StandardClaimAccessor#getPhoneNumber phoneNumber} to - * {@link GeorchestraUser#setTelephoneNumber telephoneNumber} - *
  • {@link AddressStandardClaim#getFormatted address.formatted} to - * {@link GeorchestraUser#setPostalAddress postalAddress} + *
  • {@link StandardClaimAccessor#getSubject() subject} → + * {@link GeorchestraUser#getId() id}
  • + *
  • {@link StandardClaimAccessor#getPreferredUsername() preferredUsername} or + * {@link StandardClaimAccessor#getEmail() email} → + * {@link GeorchestraUser#setUsername(String) username}
  • + *
  • {@link StandardClaimAccessor#getGivenName() givenName} → + * {@link GeorchestraUser#setFirstName(String) firstName}
  • + *
  • {@link StandardClaimAccessor#getFamilyName() familyName} → + * {@link GeorchestraUser#setLastName(String) lastName}
  • + *
  • {@link StandardClaimAccessor#getEmail() email} → + * {@link GeorchestraUser#setEmail(String) email}
  • + *
  • {@link StandardClaimAccessor#getPhoneNumber() phoneNumber} → + * {@link GeorchestraUser#setTelephoneNumber(String) telephoneNumber}
  • + *
  • {@link AddressStandardClaim#getFormatted() address.formatted} → + * {@link GeorchestraUser#setPostalAddress(String) postalAddress}
  • *
+ * *

- * Non-standard claims can be used to set {@link GeorchestraUser#setRoles roles} - * and {@link GeorchestraUser#setOrganization organization} short name by - * externalized configuration of - * {@link OpenIdConnectCustomClaimsConfigProperties}, using a JSONPath - * expression with the {@link OidcUser#getClaims()} {@code Map} - * as root object. - *

- * For example, if the OpenID Connect token contains the following claims: + * Non-standard claims can be mapped to {@link GeorchestraUser#setRoles(List) + * roles} and {@link GeorchestraUser#setOrganization(String) organization} via + * {@link OpenIdConnectCustomClaimsConfigProperties} using JSONPath expressions. + *

+ * + *

Example Configuration

If the OpenID Connect token contains the + * following claims: * *
- * 
- *  { ..., 
- *    "groups_json": [[{"name":"GDI Planer"}],[{"name":"GDI Editor"}]],
- *    "PartyOrganisationID": "6007280321",
- *     ...
- *  }
- * 
+ * {
+ *   "groups_json": [[{"name":"GDI Planer"}],[{"name":"GDI Editor"}]],
+ *   "PartyOrganisationID": "6007280321"
+ * }
  * 
* - * the following configuration in {@literal application.yml} (or other included - * configuration file): + * The following configuration in {@code application.yml}: * *
- * {@code
- *  georchestra:
- *    gateway:
- *      security:
- *        oidc:
- *          claims:
+ * georchestra:
+ *   gateway:
+ *     security:
+ *       oidc:
+ *         claims:
  *           organization.path: "$.PartyOrganisationID"
  *           roles.path: "$.groups_json..['name']"
- * }
  * 
* - * will assign {@literal "6007280321"} to - * {@link GeorchestraUser#setOrganization(String)}, and append - * {@literal ["ROLE_GDI_PLANER", "ROLE_GDI_EDITOR"]} to - * {@link GeorchestraUser#setRoles(List)}. - *

- * Additional, some control can be applied over how to map strings resolved from - * the roles JSONPath expression to internal role names through the following - * config properties: + * Will: + *

    + *
  • Assign {@code "6007280321"} to + * {@link GeorchestraUser#setOrganization(String)}
  • + *
  • Append {@code ["ROLE_GDI_PLANER", "ROLE_GDI_EDITOR"]} to + * {@link GeorchestraUser#setRoles(List)}
  • + *
+ * + *

Role Mapping Customization

Additional customization for role name + * formatting: * *
- * {@code
- *  georchestra.gateway.security.oidc.claims.roles:
+ * georchestra.gateway.security.oidc.claims.roles:
  *   path: "$.groups_json..['name']"
  *   uppercase: true
  *   normalize: true
  *   append: true
- * }
  * 
* - * With the following meanings: + * Where: *
    - *
  • {@code uppercase}: Whether to return mapped role names as upper-case. - * Defaults to {@code true}. - *
  • {@code normalize}: Whether to remove special characters and replace - * spaces by underscores. Defaults to {@code true}. - *
  • {@code append}: Whether to append the resolved role names to the roles - * given by the OAuth2 authentication. (true), or replace them (false). Defaults - * to {@code true}. + *
  • {@code uppercase}: Convert role names to uppercase (default: + * {@code true}).
  • + *
  • {@code normalize}: Remove special characters and replace spaces with + * underscores (default: {@code true}).
  • + *
  • {@code append}: Append roles to those provided by the authentication, + * rather than replacing them (default: {@code true}).
  • *
*/ @RequiredArgsConstructor @@ -137,15 +131,35 @@ public class OpenIdConnectUserMapper extends OAuth2UserMapper { private final @NonNull OpenIdConnectCustomClaimsConfigProperties nonStandardClaimsConfig; + /** + * Filters authentication tokens to process only {@link OidcUser}-based + * authentication. + * + * @return Predicate that checks if the principal is an instance of + * {@link OidcUser}. + */ protected @Override Predicate tokenFilter() { return token -> token.getPrincipal() instanceof OidcUser; } + /** + * Ensures this mapper runs before the generic {@link OAuth2UserMapper}. + * + * @return {@link Ordered#HIGHEST_PRECEDENCE} to prioritize this mapper. + */ public @Override int getOrder() { - // be evaluated before OAuth2AuthenticationTokenUserMapper return Ordered.HIGHEST_PRECEDENCE; } + /** + * Maps an OpenID Connect (OIDC) authenticated user to a + * {@link GeorchestraUser}. + * + * @param token The {@link OAuth2AuthenticationToken} containing the + * authentication information. + * @return An {@link Optional} containing the mapped {@link GeorchestraUser}, or + * empty if mapping fails. + */ protected @Override Optional map(OAuth2AuthenticationToken token) { GeorchestraUser user = super.map(token).orElseGet(GeorchestraUser::new); OidcUser oidcUser = (OidcUser) token.getPrincipal(); @@ -162,50 +176,53 @@ public class OpenIdConnectUserMapper extends OAuth2UserMapper { } /** - * @param claims OpenId Connect merged claims from {@link OidcUserInfo} and - * {@link OidcIdToken} - * @param target + * Applies non-standard claims to the {@link GeorchestraUser} based on + * {@link OpenIdConnectCustomClaimsConfigProperties}. + * + * @param claims OpenID Connect claims extracted from {@link OidcUserInfo} and + * {@link OidcIdToken}. + * @param target The {@link GeorchestraUser} to update. */ @VisibleForTesting void applyNonStandardClaims(Map claims, GeorchestraUser target) { - nonStandardClaimsConfig.id().map(jsonEvaluator -> jsonEvaluator.extract(claims))// - .map(List::stream)// - .flatMap(Stream::findFirst)// - .ifPresent(target::setId); + nonStandardClaimsConfig.id().map(jsonEvaluator -> jsonEvaluator.extract(claims)).map(List::stream) + .flatMap(Stream::findFirst).ifPresent(target::setId); nonStandardClaimsConfig.roles().ifPresent(rolesMapper -> rolesMapper.apply(claims, target)); - nonStandardClaimsConfig.organization().map(jsonEvaluator -> jsonEvaluator.extract(claims))// - .map(List::stream)// - .flatMap(Stream::findFirst)// - .ifPresent(target::setOrganization); + + nonStandardClaimsConfig.organization().map(jsonEvaluator -> jsonEvaluator.extract(claims)).map(List::stream) + .flatMap(Stream::findFirst).ifPresent(target::setOrganization); } + /** + * Applies standard OpenID Connect claims to a {@link GeorchestraUser}. + * + * @param standardClaims The OIDC standard claims. + * @param target The user to populate with standard claim values. + */ @VisibleForTesting void applyStandardClaims(StandardClaimAccessor standardClaims, GeorchestraUser target) { - String subjectId = standardClaims.getSubject(); - String preferredUsername = standardClaims.getPreferredUsername(); - String givenName = standardClaims.getGivenName(); - String familyName = standardClaims.getFamilyName(); - - String email = standardClaims.getEmail(); - String phoneNumber = standardClaims.getPhoneNumber(); + apply(target::setId, standardClaims.getSubject()); + apply(target::setUsername, standardClaims.getPreferredUsername(), standardClaims.getSubject()); + apply(target::setFirstName, standardClaims.getGivenName()); + apply(target::setLastName, standardClaims.getFamilyName()); + apply(target::setEmail, standardClaims.getEmail()); + apply(target::setTelephoneNumber, standardClaims.getPhoneNumber()); AddressStandardClaim address = standardClaims.getAddress(); - String formattedAddress = address == null ? null : address.getFormatted(); - - apply(target::setId, subjectId); - apply(target::setUsername, preferredUsername, subjectId); - apply(target::setFirstName, givenName); - apply(target::setLastName, familyName); - apply(target::setEmail, email); - apply(target::setTelephoneNumber, phoneNumber); - apply(target::setPostalAddress, formattedAddress); + apply(target::setPostalAddress, address == null ? null : address.getFormatted()); } - protected void apply(Consumer setter, String... alternativesInOrderOfPreference) { - Stream.of(alternativesInOrderOfPreference).filter(Objects::nonNull).findFirst()// - .ifPresent(setter::accept); + /** + * Applies the first non-null value from the provided alternatives to the + * setter. + * + * @param setter The setter method to apply the value to. + * @param alternatives The list of potential values in order of preference. + */ + protected void apply(Consumer setter, String... alternatives) { + Stream.of(alternatives).filter(Objects::nonNull).findFirst().ifPresent(setter::accept); } protected @Override Logger logger() { diff --git a/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreAuthenticationConfiguration.java b/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreAuthenticationConfiguration.java index e7ce5298..ed99201f 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreAuthenticationConfiguration.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreAuthenticationConfiguration.java @@ -26,48 +26,88 @@ import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; /** - * {@link Configuration @Configuration} to enable request headers - * pre-authentication. + * Configuration class for enabling request-header-based pre-authentication. *

+ * This setup allows authentication to be performed via HTTP request headers, + * typically injected by a trusted reverse proxy or identity provider. + * Authentication is only considered valid if the + * {@code sec-georchestra-preauthenticated} header is present and set to + * {@code true}. + *

+ * + *

Authentication Flow:

*
    - *
  • {@link PreauthGatewaySecurityCustomizer} performs authentication based on - * incoming {@literal preauth-*} headers and produces a - * {@link PreAuthenticatedAuthenticationToken}, provided the - * {@code sec-georchestra-preauthenticated} header has a value of {@code true}. - * This is intended to be sent by a reverse proxy, prior sanitization of - * {@code sec-*} headers from client requests to avoid fraudulent requests. - *

    - * The following request headers are parsed: + *

  • {@link PreauthGatewaySecurityCustomizer}: *
      - *
    • {@literal preauth-username} - *
    • {@literal preauth-firstname} - *
    • {@literal preauth-lastname} - *
    • {@literal preauth-org} - *
    • {@literal preauth-email} - *
    • {@literal preauth-roles} + *
    • Intercepts incoming requests and extracts pre-authentication + * headers.
    • + *
    • Creates a {@link PreAuthenticatedAuthenticationToken} if authentication + * is valid.
    • + *
    • Ensures that client requests cannot tamper with {@code sec-*} + * headers.
    • + *
    + *
  • + *
  • {@link PreauthenticatedUserMapperExtension}: + *
      + *
    • Maps a {@link PreAuthenticatedAuthenticationToken} to a + * {@link GeorchestraUser}.
    • + *
    • Used by {@link GeorchestraUserMapper} when resolving authentication.
    • + *
    + *
  • *
- * NOTE {@literal preauth-roles} is NOT mandatory, and the pre-authenticated - * user will only have the {@literal ROLE_USER} role when {@code preauth-roles} - * is not provided. - *
  • {@link PreauthenticatedUserMapperExtension} maps the - * {@link PreAuthenticatedAuthenticationToken} to a {@link GeorchestraUser} when - * {@link GeorchestraUserMapper#resolve(org.springframework.security.core.Authentication) - * GeorchestraUserMapper.resolve(Authentication)} requests it. + * + *

    Expected Headers:

    The following HTTP headers can be used for + * authentication: + *
      + *
    • {@code preauth-username} - Username of the authenticated user.
    • + *
    • {@code preauth-firstname} - User's first name.
    • + *
    • {@code preauth-lastname} - User's last name.
    • + *
    • {@code preauth-org} - Organization name.
    • + *
    • {@code preauth-email} - Email address of the user.
    • + *
    • {@code preauth-roles} - (Optional) Comma-separated list of user + * roles.
    • *
    + *

    + * Note: If {@code preauth-roles} is not provided, the user will only be + * assigned the default role {@code ROLE_USER}. + *

    + * + *

    Example Configuration:

    * + *
    + * {@code
    + * georchestra:
    + *   gateway:
    + *     security:
    + *       header-authentication:
    + *         enabled: true
    + * }
    + * 
    */ @Configuration @EnableConfigurationProperties(HeaderPreauthConfigProperties.class) public class HeaderPreAuthenticationConfiguration { + /** + * Registers a security customizer that enables authentication based on + * pre-authentication headers. + * + * @return a {@link PreauthGatewaySecurityCustomizer} bean + */ @Bean PreauthGatewaySecurityCustomizer preauthGatewaySecurityCustomizer() { return new PreauthGatewaySecurityCustomizer(); } + /** + * Registers a mapper that converts a + * {@link PreAuthenticatedAuthenticationToken} into a {@link GeorchestraUser} + * instance. + * + * @return a {@link PreauthenticatedUserMapperExtension} bean + */ @Bean PreauthenticatedUserMapperExtension preauthenticatedUserMapperExtension() { return new PreauthenticatedUserMapperExtension(); } - } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreauthConfigProperties.java b/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreauthConfigProperties.java index b4f78efb..a5223c48 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreauthConfigProperties.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/preauth/HeaderPreauthConfigProperties.java @@ -24,23 +24,55 @@ import lombok.Generated; /** - * Model object representing the externalized configuration properties used to - * set up request headers based pre-authentication. + * Configuration properties for enabling and configuring request-header-based + * pre-authentication. + *

    + * When enabled, authentication can be performed by sending a special header + * ({@code sec-georchestra-preauthenticated}) set to {@code true}, along with + * additional user identity details in the following request headers: + *

      + *
    • {@code preauth-username} - The username of the pre-authenticated + * user.
    • + *
    • {@code preauth-firstname} - The first name of the user.
    • + *
    • {@code preauth-lastname} - The last name of the user.
    • + *
    • {@code preauth-org} - The organization of the user.
    • + *
    • {@code preauth-email} - The user's email address.
    • + *
    • {@code preauth-roles} - A comma-separated list of roles assigned to the + * user.
    • + *
    + *

    + * This mechanism allows an external authentication system (e.g., a reverse + * proxy or another identity provider) to inject user identity information into + * requests without requiring direct authentication within the application. + * + *

    + * Example configuration in {@code application.yml}: + * + *

    + * {@code
    + * georchestra:
    + *   gateway:
    + *     security:
    + *       header-authentication:
    + *         enabled: true
    + * }
    + * 
    */ @Data @Generated @ConfigurationProperties(HeaderPreauthConfigProperties.PROPERTY_BASE) public class HeaderPreauthConfigProperties { + /** Base property prefix for header authentication settings. */ static final String PROPERTY_BASE = "georchestra.gateway.security.header-authentication"; + /** Property key for enabling header-based pre-authentication. */ public static final String ENABLED_PROPERTY = PROPERTY_BASE + ".enabled"; /** - * If enabled, pre-authentication is enabled and can be performed by passing - * true to the sec-georchestra-preauthenticated request header, and user details - * through the following request headers: preauth-username, preauth-firstname, - * preauth-lastname, preauth-org, preauth-email, preauth-roles + * Whether header-based pre-authentication is enabled. + *

    + * When {@code true}, authentication via request headers is allowed. */ private boolean enabled = false; } diff --git a/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthAuthenticationManager.java b/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthAuthenticationManager.java index 340b4091..36ac8562 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthAuthenticationManager.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthAuthenticationManager.java @@ -37,6 +37,49 @@ import reactor.core.publisher.Mono; +/** + * A {@link ReactiveAuthenticationManager} and + * {@link ServerAuthenticationConverter} implementation that enables + * pre-authentication based on HTTP request headers. + *

    + * This authentication mechanism is designed for use with a trusted reverse + * proxy or an identity provider that injects pre-authenticated user details + * into HTTP headers. If the {@code sec-georchestra-preauthenticated} header is + * set to {@code true}, this manager extracts user details and creates a + * {@link PreAuthenticatedAuthenticationToken}. + *

    + * + *

    Authentication Flow:

    + *
      + *
    1. Checks for the presence of the {@code sec-georchestra-preauthenticated} + * header.
    2. + *
    3. If present, extracts user details from pre-authentication headers.
    4. + *
    5. Creates a {@link PreAuthenticatedAuthenticationToken} with the extracted + * details.
    6. + *
    7. Returns the token for authentication.
    8. + *
    + * + *

    Expected Headers:

    The following request headers are parsed for + * authentication: + *
      + *
    • {@code preauth-username} - (Required) Username of the authenticated + * user.
    • + *
    • {@code preauth-email} - (Optional) User's email address.
    • + *
    • {@code preauth-firstname} - (Optional) First name of the user.
    • + *
    • {@code preauth-lastname} - (Optional) Last name of the user.
    • + *
    • {@code preauth-org} - (Optional) Organization name.
    • + *
    • {@code preauth-roles} - (Optional) Comma-separated list of user + * roles.
    • + *
    • {@code preauth-provider} - (Optional) External authentication + * provider.
    • + *
    • {@code preauth-provider-id} - (Optional) Identifier from the external + * provider.
    • + *
    + *

    + * Note: If {@code preauth-roles} is not provided, the user is assigned + * the default role {@code ROLE_USER}. + *

    + */ public class PreauthAuthenticationManager implements ReactiveAuthenticationManager, ServerAuthenticationConverter { public static final String PREAUTH_HEADER_NAME = "sec-georchestra-preauthenticated"; @@ -51,8 +94,13 @@ public class PreauthAuthenticationManager implements ReactiveAuthenticationManag public static final String PREAUTH_PROVIDER_ID = "preauth-provider-id"; /** - * @return {@code Mono.empty()} if the pre-auth request headers are not - * provided, + * Converts an incoming request into a + * {@link PreAuthenticatedAuthenticationToken} if the request contains valid + * pre-authentication headers. + * + * @param exchange the {@link ServerWebExchange} representing the request + * @return a {@link Mono} containing the authentication token, or an empty + * {@link Mono} if not pre-authenticated */ @Override public Mono convert(ServerWebExchange exchange) { @@ -69,23 +117,51 @@ public Mono convert(ServerWebExchange exchange) { return Mono.empty(); } + /** + * Extracts all pre-authentication headers from the request and returns them as + * a map. + * + * @param headers the HTTP request headers + * @return a map containing pre-authentication header values + */ private Map extract(HttpHeaders headers) { return headers.toSingleValueMap().entrySet().stream() .filter(e -> e.getKey().toLowerCase().startsWith("preauth-")) .collect(Collectors.toMap(e -> e.getKey().toLowerCase(), Map.Entry::getValue)); } + /** + * Authenticates a previously converted + * {@link PreAuthenticatedAuthenticationToken}. + * + * @param authentication the authentication token + * @return a {@link Mono} containing the authenticated token + */ @Override public Mono authenticate(Authentication authentication) { return Mono.just(authentication); } + /** + * Checks whether a request is pre-authenticated based on the presence of the + * {@code sec-georchestra-preauthenticated} header. + * + * @param exchange the server web exchange containing the request + * @return {@code true} if the request is pre-authenticated, otherwise + * {@code false} + */ public static boolean isPreAuthenticated(ServerWebExchange exchange) { HttpHeaders requestHeaders = exchange.getRequest().getHeaders(); final String preAuthHeader = requestHeaders.getFirst(PREAUTH_HEADER_NAME); return Boolean.parseBoolean(preAuthHeader); } + /** + * Maps extracted request headers into a {@link GeorchestraUser} object. + * + * @param requestHeaders a map of extracted request headers + * @return a {@link GeorchestraUser} instance populated with the extracted data + */ public static GeorchestraUser map(Map requestHeaders) { String username = SecurityHeaders.decode(requestHeaders.get(PREAUTH_USERNAME)); String email = SecurityHeaders.decode(requestHeaders.get(PREAUTH_EMAIL)); @@ -108,13 +184,19 @@ public static GeorchestraUser map(Map requestHeaders) { user.setFirstName(firstName); user.setLastName(lastName); user.setOrganization(org); - user.setRoles(new ArrayList<>(roleNames));// mutable + user.setRoles(new ArrayList<>(roleNames)); // mutable list user.setOAuth2Provider(provider); user.setOAuth2Uid(providerId); - // TODO rename oauth2 fields to a more generic name : externalProvider ? + // TODO: Consider renaming OAuth2-related fields to a more generic + // "externalProvider" return user; } + /** + * Removes pre-authentication headers from a given set of mutable HTTP headers. + * + * @param mutableHeaders the mutable {@link HttpHeaders} object to clean up + */ public void removePreauthHeaders(HttpHeaders mutableHeaders) { mutableHeaders.remove(PREAUTH_HEADER_NAME); mutableHeaders.remove(PREAUTH_USERNAME); diff --git a/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthGatewaySecurityCustomizer.java b/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthGatewaySecurityCustomizer.java index 33bf62c1..bcf97eb5 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthGatewaySecurityCustomizer.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthGatewaySecurityCustomizer.java @@ -30,25 +30,91 @@ import lombok.RequiredArgsConstructor; import reactor.core.publisher.Mono; +/** + * Customizes {@link ServerHttpSecurity} to enable pre-authentication based on + * HTTP request headers. + *

    + * This security customizer sets up an authentication filter that extracts user + * information from specific headers. If valid pre-authentication headers are + * found, authentication is performed and an authenticated user is established + * in the security context. + *

    + * + *

    Customization Steps:

    + *
      + *
    1. Creates a {@link PreauthAuthenticationManager} to handle + * authentication.
    2. + *
    3. Registers an {@link AuthenticationWebFilter} to authenticate requests + * with pre-auth headers.
    4. + *
    5. Registers a {@link RemovePreauthHeadersWebFilter} to strip pre-auth + * headers from downstream requests, preventing them from being misused by + * backend services.
    6. + *
    + * + *

    + * The authentication process is initiated if the + * {@code sec-georchestra-preauthenticated} header is present. + *

    + */ public class PreauthGatewaySecurityCustomizer implements ServerHttpSecurityCustomizer { + /** + * Configures {@link ServerHttpSecurity} to add the pre-authentication filters. + *

    + * This method does the following: + *

      + *
    • Creates an {@link AuthenticationWebFilter} with a + * {@link PreauthAuthenticationManager}.
    • + *
    • Sets the authentication converter to extract credentials from HTTP + * headers.
    • + *
    • Adds the authentication filter as the first filter in the security filter + * chain.
    • + *
    • Adds a post-processing filter to remove pre-authentication headers before + * passing the request to downstream services.
    • + *
    + * + * @param http the {@link ServerHttpSecurity} instance to configure. + */ @SuppressWarnings("deprecation") @Override public void customize(ServerHttpSecurity http) { PreauthAuthenticationManager authenticationManager = new PreauthAuthenticationManager(); AuthenticationWebFilter headerFilter = new AuthenticationWebFilter(authenticationManager); - // return Mono.empty() if preauth headers not provided + // Set the authentication converter to extract credentials from headers headerFilter.setAuthenticationConverter(authenticationManager::convert); + + // Add authentication filter at the beginning of the security filter chain http.addFilterAt(headerFilter, SecurityWebFiltersOrder.FIRST); + + // Add a filter at the end of the chain to remove pre-auth headers before + // forwarding the request http.addFilterAt(new RemovePreauthHeadersWebFilter(authenticationManager), SecurityWebFiltersOrder.LAST); } + /** + * A {@link WebFilter} that removes pre-authentication headers from the request + * before passing it to the next filter in the chain. + *

    + * This ensures that backend services do not see or rely on the + * pre-authentication headers, which could otherwise be misused. + *

    + */ @RequiredArgsConstructor static class RemovePreauthHeadersWebFilter implements WebFilter { private final PreauthAuthenticationManager manager; + /** + * Filters incoming requests by removing pre-authentication headers before + * continuing the chain. + * + * @param exchange the {@link ServerWebExchange} representing the HTTP request + * and response. + * @param chain the {@link WebFilterChain} to delegate further request + * processing. + * @return a {@link Mono} indicating when request processing is complete. + */ @Override public Mono filter(ServerWebExchange exchange, WebFilterChain chain) { ServerHttpRequest request = exchange.getRequest().mutate().headers(manager::removePreauthHeaders).build(); diff --git a/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthenticatedUserMapperExtension.java b/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthenticatedUserMapperExtension.java index 944cdfb9..9a7f4802 100644 --- a/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthenticatedUserMapperExtension.java +++ b/gateway/src/main/java/org/georchestra/gateway/security/preauth/PreauthenticatedUserMapperExtension.java @@ -26,16 +26,47 @@ import org.springframework.security.core.Authentication; import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationToken; +/** + * A {@link GeorchestraUserMapperExtension} implementation that maps a + * {@link PreAuthenticatedAuthenticationToken} to a {@link GeorchestraUser}. + *

    + * This class extracts user details from the credentials of the authentication + * token, which are expected to be a {@link Map} containing pre-authenticated + * user attributes. + *

    + * + *

    Mapping Logic:

    + *
      + *
    1. Verifies that the provided authentication token is a + * {@link PreAuthenticatedAuthenticationToken}.
    2. + *
    3. Extracts the credentials from the token, expecting them to be a + * {@link Map}.
    4. + *
    5. Uses {@link PreauthAuthenticationManager#map(Map)} to convert the + * extracted attributes into a {@link GeorchestraUser}.
    6. + *
    + *

    + * If the token does not meet these conditions, an empty {@link Optional} is + * returned. + *

    + */ public class PreauthenticatedUserMapperExtension implements GeorchestraUserMapperExtension { + /** + * Resolves a {@link GeorchestraUser} from a pre-authenticated authentication + * token. + * + * @param authToken the authentication token to resolve + * @return an {@link Optional} containing the mapped {@link GeorchestraUser}, or + * empty if resolution fails + */ @Override public Optional resolve(Authentication authToken) { return Optional.ofNullable(authToken)// - .filter(PreAuthenticatedAuthenticationToken.class::isInstance) + .filter(PreAuthenticatedAuthenticationToken.class::isInstance) // Ensure token type .map(PreAuthenticatedAuthenticationToken.class::cast)// - .map(PreAuthenticatedAuthenticationToken::getCredentials)// - .filter(Map.class::isInstance)// - .map(Map.class::cast).map(PreauthAuthenticationManager::map); + .map(PreAuthenticatedAuthenticationToken::getCredentials) // Extract credentials + .filter(Map.class::isInstance) // Ensure credentials are a Map + .map(Map.class::cast)// + .map(PreauthAuthenticationManager::map); // Convert to GeorchestraUser } - } diff --git a/gateway/src/main/java/org/geoserver/cloud/gateway/filter/GlobalUriFilter.java b/gateway/src/main/java/org/geoserver/cloud/gateway/filter/GlobalUriFilter.java index 2cb23bef..5cac3926 100644 --- a/gateway/src/main/java/org/geoserver/cloud/gateway/filter/GlobalUriFilter.java +++ b/gateway/src/main/java/org/geoserver/cloud/gateway/filter/GlobalUriFilter.java @@ -20,36 +20,76 @@ import reactor.core.publisher.Mono; /** - * See gateway's issue #2065 - * "Double Encoded URLs" + * Global filter to correct double-encoded URLs in Spring Cloud Gateway. + *

    + * This filter addresses issue #2065, + * where request URIs may be unintentionally double-encoded during request + * processing. + *

    + * When a request URI is already encoded (i.e., it contains percent-encoded + * characters), this filter ensures that it is not further re-encoded by the + * {@link ReactiveLoadBalancerClientFilter}. + *

    + * The filter does the following: + *

      + *
    1. Checks if the incoming request URI is already encoded.
    2. + *
    3. Retrieves the original {@link Route} for the request.
    4. + *
    5. Overrides the incorrectly re-encoded URI by merging the original request + * URI with the target load-balanced service URL.
    6. + *
    + * + * @see ReactiveLoadBalancerClientFilter */ @Component public class GlobalUriFilter implements GlobalFilter, Ordered { + /** + * Intercepts requests to check for double-encoded URIs and fixes them before + * further processing. + * + * @param exchange the {@link ServerWebExchange} containing request and response + * details + * @param chain the {@link GatewayFilterChain} to continue request processing + * @return a {@link Mono} indicating when request processing is complete + */ @Override public Mono filter(ServerWebExchange exchange, GatewayFilterChain chain) { URI incomingUri = exchange.getRequest().getURI(); if (isUriEncoded(incomingUri)) { - // Get the original Gateway route (contains the service's original host) + // Retrieve the route associated with the request Route route = exchange.getAttribute(GATEWAY_ROUTE_ATTR); if (route == null) { return chain.filter(exchange); } - // Save it as the outgoing URI to call the service, and override the "wrongly" - // double encoded URI in ReactiveLoadBalancerClientFilter - // LoadBalancerUriTools::containsEncodedParts - // double encoded URI again + // Retrieve the load-balanced service URI (computed by + // ReactiveLoadBalancerClientFilter) URI balanceUrl = exchange.getRequiredAttribute(GATEWAY_REQUEST_URL_ATTR); + + // Construct the corrected URI to prevent double encoding URI mergedUri = createUri(incomingUri, balanceUrl); + + // Override the wrongly encoded URI in the exchange attributes exchange.getAttributes().put(GATEWAY_REQUEST_URL_ATTR, mergedUri); } return chain.filter(exchange); } + /** + * Creates a correctly formatted URI by merging the incoming request URI with + * the load-balanced service URL. + *

    + * This method ensures that the original request's query parameters and path + * remain intact while applying the proper host and scheme from the load + * balancer. + * + * @param incomingUri the original request URI + * @param balanceUrl the load-balanced target service URI + * @return a corrected {@link URI} with proper encoding and formatting + */ private URI createUri(URI incomingUri, URI balanceUrl) { final var port = balanceUrl.getPort() != -1 ? ":" + balanceUrl.getPort() : ""; final var rawPath = balanceUrl.getRawPath() != null ? balanceUrl.getRawPath() : ""; @@ -57,12 +97,29 @@ private URI createUri(URI incomingUri, URI balanceUrl) { return URI.create(balanceUrl.getScheme() + "://" + balanceUrl.getHost() + port + rawPath + query); } + /** + * Checks if a URI is already encoded by looking for percent-encoded characters + * in the path or query. + * + * @param uri the {@link URI} to check + * @return {@code true} if the URI contains percent-encoded characters, + * otherwise {@code false} + */ private static boolean isUriEncoded(URI uri) { return (uri.getRawQuery() != null && uri.getRawQuery().contains("%")) || (uri.getRawPath() != null && uri.getRawPath().contains("%")); } - // order after ReactiveLoadBalancerClientFilter + /** + * Defines the order of execution for this filter. + *

    + * This filter runs immediately after the + * {@link ReactiveLoadBalancerClientFilter} to correct double-encoded URIs + * before they are processed further. + * + * @return the filter execution order, which is one position after + * {@link ReactiveLoadBalancerClientFilter#LOAD_BALANCER_CLIENT_FILTER_ORDER} + */ @Override public int getOrder() { return ReactiveLoadBalancerClientFilter.LOAD_BALANCER_CLIENT_FILTER_ORDER + 1; diff --git a/gateway/src/main/java/org/geoserver/cloud/gateway/filter/RouteProfileGatewayFilterFactory.java b/gateway/src/main/java/org/geoserver/cloud/gateway/filter/RouteProfileGatewayFilterFactory.java index e354243a..ccd2cd9f 100644 --- a/gateway/src/main/java/org/geoserver/cloud/gateway/filter/RouteProfileGatewayFilterFactory.java +++ b/gateway/src/main/java/org/geoserver/cloud/gateway/filter/RouteProfileGatewayFilterFactory.java @@ -28,7 +28,18 @@ import lombok.experimental.Accessors; import reactor.core.publisher.Mono; -/** Allows to enable routes only if a given spring profile is enabled */ +/** + * A Gateway filter factory that conditionally enables or disables routes based + * on the presence or absence of a specified Spring profile. + *

    + * This filter is useful for dynamically controlling route availability + * depending on the application's active profiles. If the configured profile is + * active, the request is allowed to proceed; otherwise, the request is rejected + * with the specified HTTP status code. + *

    + * Profiles can be negated using the {@code !} prefix. If a profile is prefixed + * with {@code !}, the route will be disabled if the profile is active. + */ public class RouteProfileGatewayFilterFactory extends AbstractGatewayFilterFactory { @@ -52,6 +63,14 @@ public GatewayFilter apply(Config config) { return new RouteProfileGatewayFilter(environment, config); } + /** + * A filter that conditionally allows requests based on the presence of a + * specified Spring profile. + *

    + * If the required profile is active, the request proceeds. If the profile is + * negated (e.g., {@code !profileName}), the request is blocked if the profile + * is active. + */ @RequiredArgsConstructor private static class RouteProfileGatewayFilter implements GatewayFilter { @@ -86,20 +105,29 @@ public String toString() { } } + /** + * Configuration class for {@link RouteProfileGatewayFilterFactory}. + *

    + * Defines the profile condition and HTTP status code to return if the condition + * is not met. + */ @Data @Accessors(chain = true) @Validated public static class Config { /** - * Profiles key, indicates which profiles must be enabled to allow the request - * to proceed + * The profile name that must be active for the request to proceed. + *

    + * If prefixed with {@code !}, the request is blocked if the profile is active. */ public static final String PROFILE_KEY = "profile"; /** - * Status code key. HTTP status code to return when the request is not allowed - * to proceed because the required profiles are not active + * The HTTP status code to return when the request is blocked due to profile + * conditions. + *

    + * Defaults to {@link HttpStatus#NOT_FOUND} (404). */ public static final String HTTPSTATUS_KEY = "statusCode"; diff --git a/gateway/src/main/java/org/geoserver/cloud/gateway/filter/StripBasePathGatewayFilterFactory.java b/gateway/src/main/java/org/geoserver/cloud/gateway/filter/StripBasePathGatewayFilterFactory.java index 3157e3c9..84a9040f 100644 --- a/gateway/src/main/java/org/geoserver/cloud/gateway/filter/StripBasePathGatewayFilterFactory.java +++ b/gateway/src/main/java/org/geoserver/cloud/gateway/filter/StripBasePathGatewayFilterFactory.java @@ -18,9 +18,24 @@ import lombok.Data; /** - * See gateway's issue #1759 - * "Webflux base path does not work with Path predicates" + * A {@link GatewayFilter} factory that strips a base path prefix from the + * incoming request URI. + *

    + * This filter is useful in scenarios where requests contain a base path that + * needs to be removed for downstream processing. The base path is specified in + * the {@link PrefixConfig} and must meet the following conditions: + *

      + *
    • The prefix must start with a '/' character.
    • + *
    • The prefix must not end with a '/' unless it is exactly '/'.
    • + *
    + *

    + * This filter works by calculating how many segments of the URI need to be + * removed based on the configured prefix. If the prefix is found in the request + * URI, it is stripped before the request is forwarded. + *

    + * For more details, see issue + * #1759 */ public class StripBasePathGatewayFilterFactory extends AbstractGatewayFilterFactory { @@ -31,11 +46,17 @@ public StripBasePathGatewayFilterFactory() { super(PrefixConfig.class); } + /** + * {@inheritDoc} + */ @Override public List shortcutFieldOrder() { return List.of("prefix"); } + /** + * {@inheritDoc} + */ @Override public GatewayFilter apply(PrefixConfig config) { config.checkPreconditions(); @@ -44,46 +65,84 @@ public GatewayFilter apply(PrefixConfig config) { final String basePath = config.getPrefix(); final String path = request.getURI().getRawPath(); - // if (basePath.equals(path)) { - // return chain.filter(exchange); - // } + // Calculate how many parts of the path to strip based on the base path final int partsToRemove = resolvePartsToStrip(basePath, path); if (partsToRemove == 0) { - return chain.filter(exchange); + return chain.filter(exchange); // No base path to strip, continue with the chain } + + // Create and apply the StripPrefix filter with the correct number of parts to + // remove GatewayFilter stripFilter = stripPrefix.apply(newStripPrefixConfig(partsToRemove)); return stripFilter.filter(exchange, chain); }; } + /** + * Creates a new configuration for the {@link StripPrefixGatewayFilterFactory} + * with the specified number of parts to remove from the URI. + * + * @param partsToRemove the number of URI path segments to strip + * @return a new {@link Config} for the StripPrefix filter + */ private Config newStripPrefixConfig(int partsToRemove) { Config config = stripPrefix.newConfig(); config.setParts(partsToRemove); return config; } + /** + * Resolves the number of URI path segments to strip based on the base path and + * the incoming request URI. + * + * @param basePath the base path to strip + * @param requestPath the incoming request path + * @return the number of path segments to strip + */ private int resolvePartsToStrip(String basePath, String requestPath) { - if (null == basePath) - return 0; + if (null == basePath) { + return 0; // No prefix to strip + } if (!requestPath.startsWith(basePath)) { - return 0; + return 0; // Base path is not part of the request URI } + final int basePathSteps = StringUtils.countOccurrencesOf(basePath, "/"); boolean isRoot = basePath.equals(requestPath); - return isRoot ? basePathSteps - 1 : basePathSteps; + return isRoot ? basePathSteps - 1 : basePathSteps; // Calculate how many parts to remove } - public static @Data class PrefixConfig { + /** + * Configuration class for the {@link StripBasePathGatewayFilterFactory}. + *

    + * Defines the prefix to be stripped from the incoming URI. The prefix must meet + * specific constraints as follows: + *

      + *
    • It must start with '/'.
    • + *
    • If it is not '/', it must not end with '/'.
    • + *
    + */ + @Data + public static class PrefixConfig { + private String prefix; + /** + * Validates the preconditions for the {@link PrefixConfig}. + *

    + * Ensures that the prefix: + *

      + *
    • Starts with '/'.
    • + *
    • If not '/', does not end with '/'.
    • + *
    + */ public void checkPreconditions() { final String prefix = getPrefix(); - // requireNonNull(prefix, "StripBasePath prefix can't be null"); + // Ensure the prefix is valid if (prefix != null) { checkArgument(prefix.startsWith("/"), "StripBasePath prefix must start with /"); - checkArgument("/".equals(prefix) || !prefix.endsWith("/"), "StripBasePath prefix must not end with /"); } } diff --git a/gateway/src/main/java/org/geoserver/cloud/gateway/predicate/RegExpQueryRoutePredicateFactory.java b/gateway/src/main/java/org/geoserver/cloud/gateway/predicate/RegExpQueryRoutePredicateFactory.java index 5d762c4e..649f7630 100644 --- a/gateway/src/main/java/org/geoserver/cloud/gateway/predicate/RegExpQueryRoutePredicateFactory.java +++ b/gateway/src/main/java/org/geoserver/cloud/gateway/predicate/RegExpQueryRoutePredicateFactory.java @@ -69,23 +69,51 @@ public class RegExpQueryRoutePredicateFactory /** HTTP request query parameter value regexp key. */ public static final String VALUE_KEY = "valueRegexp"; + /** + * Constructs a new instance of {@link RegExpQueryRoutePredicateFactory}. + */ public RegExpQueryRoutePredicateFactory() { super(Config.class); } - public @Override List shortcutFieldOrder() { + /** + * Returns the order of the shortcut fields. + * + * @return the field order list. + */ + @Override + public List shortcutFieldOrder() { return Arrays.asList(PARAM_KEY, VALUE_KEY); } - public @Override Predicate apply(Config config) { + /** + * Applies the given configuration to create a {@link GatewayPredicate}. + * + * @param config the configuration to apply. + * @return a {@link GatewayPredicate} based on the provided config. + */ + @Override + public Predicate apply(Config config) { return new RegExpQueryRoutePredicate(config); } + /** + * A {@link GatewayPredicate} implementation for matching query parameters based + * on regular expressions. + */ @RequiredArgsConstructor private static class RegExpQueryRoutePredicate implements GatewayPredicate { private final @NonNull Config config; - public @Override boolean test(ServerWebExchange exchange) { + /** + * Tests if the given exchange matches the predicate based on the configured + * regular expressions. + * + * @param exchange the exchange to test. + * @return true if the predicate matches the exchange, false otherwise. + */ + @Override + public boolean test(ServerWebExchange exchange) { final String paramRegexp = config.getParamRegexp(); final String valueRegexp = config.getValueRegexp(); @@ -98,31 +126,59 @@ private static class RegExpQueryRoutePredicate implements GatewayPredicate { return paramNameMatches && paramValueMatches(paramName.get(), valueRegexp, exchange); } - public @Override String toString() { + /** + * Provides a string representation of this predicate. + * + * @return a string describing this predicate. + */ + @Override + public String toString() { return String.format("Query: param regexp='%s' value regexp='%s'", config.getParamRegexp(), config.getValueRegexp()); } } + /** + * Finds the first query parameter that matches the provided regular expression. + * + * @param regex the regular expression to match the parameter name. + * @param exchange the exchange containing the request. + * @return an optional containing the parameter name if a match is found, or + * empty otherwise. + */ static Optional findParameterName(@NonNull String regex, ServerWebExchange exchange) { Set parameterNames = exchange.getRequest().getQueryParams().keySet(); return parameterNames.stream().filter(name -> name.matches(regex)).findFirst(); } + /** + * Checks if the value of the query parameter matches the provided regular + * expression. + * + * @param paramName the name of the parameter. + * @param valueRegEx the regular expression to match the parameter value. + * @param exchange the exchange containing the request. + * @return true if a matching value is found, false otherwise. + */ static boolean paramValueMatches(@NonNull String paramName, @NonNull String valueRegEx, ServerWebExchange exchange) { List values = exchange.getRequest().getQueryParams().get(paramName); return values != null && values.stream().anyMatch(v -> v != null && v.matches(valueRegEx)); } + /** + * Configuration class for the {@link RegExpQueryRoutePredicateFactory}. + */ @Data @Accessors(chain = true) @Validated public static class Config { + /** The regular expression for the query parameter name. */ @NotEmpty private String paramRegexp; + /** The regular expression for the query parameter value (optional). */ private String valueRegexp; } } diff --git a/gateway/src/test/java/org/georchestra/gateway/security/RolesMappingsUserCustomizerTest.java b/gateway/src/test/java/org/georchestra/gateway/security/RolesMappingsUserCustomizerTest.java index ac2587e6..0c685626 100644 --- a/gateway/src/test/java/org/georchestra/gateway/security/RolesMappingsUserCustomizerTest.java +++ b/gateway/src/test/java/org/georchestra/gateway/security/RolesMappingsUserCustomizerTest.java @@ -60,11 +60,11 @@ private void addConfig(String role, String... additionalRoles) { void constructorCreatesValidPatterns() { Pattern pattern; - pattern = RolesMappingsUserCustomizer.toPattern("ROLE.GDI.USER"); + pattern = RolesMappingsUserCustomizer.compilePattern("ROLE.GDI.USER"); assertTrue(pattern.matcher("ROLE.GDI.USER").matches()); assertFalse(pattern.matcher("ROLE.GDI_USER").matches()); - pattern = RolesMappingsUserCustomizer.toPattern("ROLE.*.*.ADMIN"); + pattern = RolesMappingsUserCustomizer.compilePattern("ROLE.*.*.ADMIN"); assertTrue(pattern.matcher("ROLE.GDI.GS.ADMIN").matches()); assertFalse(pattern.matcher("ROLE.GDI.GS.USER").matches()); } diff --git a/gateway/src/test/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidationsTest.java b/gateway/src/test/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidationsTest.java index bdece7fa..7a0a182d 100644 --- a/gateway/src/test/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidationsTest.java +++ b/gateway/src/test/java/org/georchestra/gateway/security/ldap/LdapConfigPropertiesValidationsTest.java @@ -86,7 +86,7 @@ void validates_common_url_is_mandatory_if_enabled() { , "georchestra.gateway.security.ldap.basic2.enabled: false" // , "georchestra.gateway.security.ldap.basic2.url:" // ).run(context -> { - assertThat(context).getFailure().hasStackTraceContaining("LDAP url is required") + assertThat(context).getFailure().hasStackTraceContaining("LDAP URL is required") .hasStackTraceContaining("ldap.[basic1].url").hasStackTraceContaining("ldap.[extended1].url") .hasStackTraceContaining("ldap.[ad1].url"); }); @@ -161,7 +161,7 @@ void validates_basic_and_extended_users_searchFilter_is_mandatory() { , "georchestra.gateway.security.ldap.extended1.users.searchFilter: " // ).run(context -> { assertThat(context).getFailure()// - .hasStackTraceContaining("LDAP users searchFilter is required for regular LDAP configs")// + .hasStackTraceContaining("LDAP users search filter is required for standard LDAP configurations")// .hasStackTraceContaining("ldap.[ldap1].users.searchFilter")// .hasStackTraceContaining("ldap.[extended1].users.searchFilter"); }); @@ -187,7 +187,7 @@ void validates_basic_and_extended_roles_rdn_is_mandatory() { , "georchestra.gateway.security.ldap.extended1.roles.rdn: " // ).run(context -> { assertThat(context).getFailure()// - .hasStackTraceContaining("Roles Relative distinguished name is required")// + .hasStackTraceContaining("Roles Relative Distinguished Name is required")// .hasStackTraceContaining("ldap.[ldap1].roles.rdn")// .hasStackTraceContaining("ldap.[extended1].roles.rdn"); }); @@ -215,7 +215,7 @@ void validates_basic_and_extended_roles_searchFilter_is_mandatory() { , "georchestra.gateway.security.ldap.extended1.roles.searchFilter: "// ).run(context -> { assertThat(context).getFailure()// - .hasStackTraceContaining("Roles searchFilter is required")// + .hasStackTraceContaining("Roles search filter is required")// .hasStackTraceContaining("ldap.[ldap1].roles.searchFilter")// .hasStackTraceContaining("ldap.[extended1].roles.searchFilter"); }); @@ -235,7 +235,7 @@ void validates_extended_orgs_rdn_is_mandatory() { , "georchestra.gateway.security.ldap.extended1.orgs.rdn: " // ).run(context -> { assertThat(context).getFailure()// - .hasStackTraceContaining("Organizations search base RDN is required if extended is true")// + .hasStackTraceContaining("Organizations search base RDN is required if 'extended' is true")// .hasStackTraceContaining("ldap.[extended1].orgs.rdn"); }); }