diff --git a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java index 3f1bd2b2..a4ec6fc0 100644 --- a/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java +++ b/openespi-authserver/src/main/java/org/greenbuttonalliance/espi/authserver/config/AuthorizationServerConfig.java @@ -106,6 +106,12 @@ public class AuthorizationServerConfig { @Value("${espi.authorization-server.client-secret:datacustodian-secret}") private String clientSecret; + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") + private String jwtIssuerUri; + + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:}") + private String jwtJwkSetUri; + /** * OAuth2 Authorization Server Security Filter Chain @@ -125,7 +131,11 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll()) .formLogin(Customizer.withDefaults()) .oauth2AuthorizationServer(authorizationServer -> - authorizationServer.oidc(Customizer.withDefaults()) // Enable OpenID Connect 1.0 + { + if (isJwtResourceServerConfigured()) { + authorizationServer.oidc(Customizer.withDefaults()); // Enable OpenID Connect 1.0 + } + } ) .authorizeHttpRequests(authorize -> authorize.anyRequest().authenticated()) .csrf(Customizer.withDefaults()) @@ -137,11 +147,13 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h ) ) // Accept access tokens for User Info and/or Client Registration - .oauth2ResourceServer(resourceServer -> resourceServer - .opaqueToken(Customizer.withDefaults()) - - //.jwt(Customizer.withDefaults()) - ) + .oauth2ResourceServer(resourceServer -> { + if (isJwtResourceServerConfigured()) { + resourceServer.jwt(Customizer.withDefaults()); + } else { + resourceServer.opaqueToken(Customizer.withDefaults()); + } + }) // HTTPS Channel Security for Production //should be able to use property server.ssl.enabled=true //todo - test this @@ -172,6 +184,11 @@ public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity h return http.build(); } + private boolean isJwtResourceServerConfigured() { + return (jwtIssuerUri != null && !jwtIssuerUri.isBlank()) + || (jwtJwkSetUri != null && !jwtJwkSetUri.isBlank()); + } + /** * Default Security Filter Chain for non-OAuth2 endpoints *

@@ -406,4 +423,4 @@ private static KeyPair generateRsaKey() { } return keyPair; } -} \ No newline at end of file +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/ElectricPowerQualitySummaryMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/ElectricPowerQualitySummaryMapper.java index 6c36049d..8d84c444 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/ElectricPowerQualitySummaryMapper.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/ElectricPowerQualitySummaryMapper.java @@ -30,10 +30,10 @@ /** * MapStruct mapper for converting between ElectricPowerQualitySummaryEntity and ElectricPowerQualitySummaryDto. - * + *

* Maps only ElectricPowerQualitySummary fields. IdentifiedObject fields are NOT part of the usage.xsd * definition and are handled by AtomFeedDto/AtomEntryDto. - * + *

* Handles the conversion between the JPA entity used for persistence and the DTO * used for JAXB XML marshalling in the Green Button API. */ @@ -59,6 +59,14 @@ public interface ElectricPowerQualitySummaryMapper { * @param dto the electric power quality summary DTO * @return the electric power quality summary entity */ + @Mapping(target = "updated", ignore = true) + @Mapping(target = "upLink", ignore = true) + @Mapping(target = "selfLink", ignore = true) + @Mapping(target = "relatedLinks", ignore = true) + @Mapping(target = "relatedLinkHrefs", ignore = true) + @Mapping(target = "published", ignore = true) + @Mapping(target = "description", ignore = true) + @Mapping(target = "created", ignore = true) @Mapping(target = "id", ignore = true) // IdentifiedObject field handled by Atom layer @Mapping(target = "usagePoint", ignore = true) // Relationships handled separately @Mapping(target = "upResource", ignore = true) diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/LineItemMapper.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/LineItemMapper.java index c2f89d56..7fb195bc 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/LineItemMapper.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/mapper/usage/LineItemMapper.java @@ -48,6 +48,8 @@ public interface LineItemMapper { * @param dto the line item DTO * @return the line item entity */ + @Mapping(target = "dateTimeFromLocalDateTime", ignore = true) + @Mapping(target = "dateTimeFromInstant", ignore = true) @Mapping(target = "id", ignore = true) @Mapping(target = "usageSummary", ignore = true) LineItemEntity toEntity(LineItemDto dto); diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerRepository.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerRepository.java index 82bb79d5..385035c9 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerRepository.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/customer/CustomerRepository.java @@ -21,7 +21,6 @@ import org.greenbuttonalliance.espi.common.domain.customer.entity.CustomerEntity; import org.springframework.data.jpa.repository.JpaRepository; -import org.springframework.stereotype.Repository; import java.util.UUID; @@ -35,7 +34,6 @@ * Removed queries: findByCustomerName, findByKind, findByPucNumber, findVipCustomers, * findCustomersWithSpecialNeeds, findByLocale, findByPriorityRange, findByOrganisationName */ -@Repository public interface CustomerRepository extends JpaRepository { // Only default JpaRepository methods are supported (findById, findAll, save, delete, etc.) } \ No newline at end of file diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/IntervalBlockRepository.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/IntervalBlockRepository.java index cb7f24e9..491d8b55 100755 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/IntervalBlockRepository.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/repositories/usage/IntervalBlockRepository.java @@ -23,19 +23,16 @@ import org.springframework.data.jpa.repository.JpaRepository; import org.springframework.data.jpa.repository.Query; import org.springframework.data.repository.query.Param; -import org.springframework.stereotype.Repository; import java.util.List; import java.util.UUID; -@Repository public interface IntervalBlockRepository extends JpaRepository { @Query("SELECT i.id FROM IntervalBlockEntity i") List findAllIds(); - @Query("SELECT i FROM IntervalBlockEntity i WHERE i.meterReading.id = :meterReadingId") - List findAllByMeterReadingId(@Param("meterReadingId") UUID meterReadingId); + List findAllByMeterReadingId(UUID meterReadingId); @Query("SELECT i.id FROM IntervalBlockEntity i WHERE i.meterReading.usagePoint.id = :usagePointId") List findAllIdsByUsagePointId(@Param("usagePointId") UUID usagePointId); diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ApplicationInformationService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ApplicationInformationService.java index 88ab2fa5..2b4f557f 100755 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ApplicationInformationService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/ApplicationInformationService.java @@ -20,9 +20,12 @@ package org.greenbuttonalliance.espi.common.service; import org.greenbuttonalliance.espi.common.domain.usage.ApplicationInformationEntity; +import org.greenbuttonalliance.espi.common.dto.usage.ApplicationInformationDto; import java.io.InputStream; +import java.io.OutputStream; import java.util.List; +import java.util.UUID; public interface ApplicationInformationService { @@ -53,4 +56,17 @@ public ApplicationInformationEntity findByDataCustodianClientId( */ public ApplicationInformationEntity importResource(InputStream stream); + List findAll(); + + ApplicationInformationEntity findById(UUID id); + + ApplicationInformationEntity save(ApplicationInformationEntity entity); + + void deleteById(UUID id); + + void export(List entities, OutputStream outputStream); + + void export(ApplicationInformationEntity entity, OutputStream outputStream); + + ApplicationInformationEntity fromDto(ApplicationInformationDto dto); } diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/EspiIdGeneratorService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/EspiIdGeneratorService.java index 5ac1929a..15f470e0 100644 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/EspiIdGeneratorService.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/EspiIdGeneratorService.java @@ -30,7 +30,7 @@ /** * Service for generating NAESB ESPI compliant UUID type 5 identifiers. - * + *

* This service generates deterministic UUID5 identifiers based on href URLs * to ensure ESPI compliance and consistency across the system. */ @@ -45,7 +45,7 @@ public class EspiIdGeneratorService { /** * Generates a NAESB ESPI compliant UUID5 based on the provided href URL. - * + *

* UUID5 uses SHA-1 hashing to create deterministic identifiers, ensuring * that the same href will always generate the same UUID. * diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationExportService.java new file mode 100644 index 00000000..fc3e6431 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationExportService.java @@ -0,0 +1,73 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.greenbuttonalliance.espi.common.service.impl; + +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import org.greenbuttonalliance.espi.common.service.BaseExportService; +import org.springframework.stereotype.Service; + +import java.util.Set; + +/** + * Export service for ESPI ApplicationInformation resource. + *

+ * This service handles XML marshalling for the ApplicationInformation resource defined in espi.xsd. + *

+ * Namespace configuration: + * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace + * - ESPI namespace (http://naesb.org/espi) - with "espi:" prefix + */ +@Service("applicationInformationExportService") +public class ApplicationInformationExportService extends BaseExportService { + + private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"; + private static final String ESPI_NAMESPACE = "http://naesb.org/espi"; + + /** + * Creates JAXBContext with Atom + ApplicationInformation domain classes. + * + * @return JAXBContext configured for ApplicationInformation resource + * @throws JAXBException if context creation fails + */ + @Override + protected JAXBContext createJAXBContext() throws JAXBException { + return JAXBContext.newInstance( + // Atom protocol classes + org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class, + org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class, + org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto.class, + + // ApplicationInformation resource class (http://naesb.org/espi) + org.greenbuttonalliance.espi.common.dto.usage.ApplicationInformationDto.class + ); + } + + /** + * Returns the 2 namespaces for ApplicationInformation domain. + * + * @return set containing Atom and ESPI namespaces + */ + @Override + protected Set getDomainNamespaces() { + return Set.of(ATOM_NAMESPACE, ESPI_NAMESPACE); + } +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationServiceImpl.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationServiceImpl.java index c0f70295..c485777b 100755 --- a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationServiceImpl.java +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationServiceImpl.java @@ -31,7 +31,10 @@ import org.springframework.util.Assert; import java.io.InputStream; +import java.io.OutputStream; +import java.util.List; import java.util.Optional; +import java.util.UUID; @Slf4j @Service @@ -44,6 +47,7 @@ public class ApplicationInformationServiceImpl implements private final ApplicationInformationRepository applicationInformationRepository; private final ApplicationInformationMapper applicationInformationMapper; + private final ApplicationInformationExportService applicationInformationExportService; @Override public ApplicationInformationEntity findByClientId(String clientId) { @@ -66,10 +70,8 @@ public ApplicationInformationEntity findByDataCustodianClientId( String dataCustodianClientId) { Assert.notNull(dataCustodianClientId, "dataCustodianClientId is required"); - // TODO: Add repository method findByDataCustodianClientId if needed log.info("Finding ApplicationInformation by dataCustodianClientId: " + dataCustodianClientId); - - return null; + return applicationInformationRepository.findByDataCustodianId(dataCustodianClientId).orElse(null); } @Override @@ -91,4 +93,44 @@ public ApplicationInformationEntity importResource(InputStream stream) { return null; } } + + @Override + @Transactional(readOnly = true) + public List findAll() { + return applicationInformationRepository.findAll(); + } + + @Override + @Transactional(readOnly = true) + public ApplicationInformationEntity findById(UUID id) { + return applicationInformationRepository.findById(id).orElse(null); + } + + @Override + public ApplicationInformationEntity save(ApplicationInformationEntity entity) { + return applicationInformationRepository.save(entity); + } + + @Override + public void deleteById(UUID id) { + applicationInformationRepository.deleteById(id); + } + + @Override + public void export(List entities, OutputStream outputStream) { + List dtos = entities.stream() + .map(applicationInformationMapper::toDto) + .toList(); + applicationInformationExportService.exportDto(dtos, outputStream); + } + + @Override + public void export(ApplicationInformationEntity entity, OutputStream outputStream) { + applicationInformationExportService.exportDto(applicationInformationMapper.toDto(entity), outputStream); + } + + @Override + public ApplicationInformationEntity fromDto(ApplicationInformationDto dto) { + return applicationInformationMapper.toEntity(dto); + } } diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerAccountExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerAccountExportService.java new file mode 100644 index 00000000..38970fe9 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/CustomerAccountExportService.java @@ -0,0 +1,72 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.service.impl; + +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import org.greenbuttonalliance.espi.common.service.BaseExportService; +import org.springframework.stereotype.Service; + +import java.util.Set; + +/** + * Export service for ESPI CustomerAccount resource. + *

+ * This service handles XML marshalling for the CustomerAccount resource defined in customer.xsd. + *

+ * Namespace configuration: + * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace + * - Customer namespace (http://naesb.org/espi/customer) - with "cust:" prefix + */ +@Service("customerAccountExportService") +public class CustomerAccountExportService extends BaseExportService { + + private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"; + private static final String CUSTOMER_NAMESPACE = "http://naesb.org/espi/customer"; + + /** + * Creates JAXBContext with Atom + CustomerAccount domain classes. + * + * @return JAXBContext configured for CustomerAccount resource + * @throws JAXBException if context creation fails + */ + @Override + protected JAXBContext createJAXBContext() throws JAXBException { + return JAXBContext.newInstance( + // Atom protocol classes + org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class, + org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class, + org.greenbuttonalliance.espi.common.dto.atom.CustomerAtomEntryDto.class, + + // CustomerAccount resource class (http://naesb.org/espi/customer) + org.greenbuttonalliance.espi.common.dto.customer.CustomerAccountDto.class + ); + } + + /** + * Returns the 2 namespaces for CustomerAccount domain. + * + * @return set containing Atom and Customer namespaces + */ + @Override + protected Set getDomainNamespaces() { + return Set.of(ATOM_NAMESPACE, CUSTOMER_NAMESPACE); + } +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ElectricPowerQualitySummaryExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ElectricPowerQualitySummaryExportService.java new file mode 100644 index 00000000..d0e6c58e --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ElectricPowerQualitySummaryExportService.java @@ -0,0 +1,72 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.service.impl; + +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import org.greenbuttonalliance.espi.common.service.BaseExportService; +import org.springframework.stereotype.Service; + +import java.util.Set; + +/** + * Export service for ESPI ElectricPowerQualitySummary resource. + *

+ * This service handles XML marshalling for the ElectricPowerQualitySummary resource defined in usage.xsd. + *

+ * Namespace configuration: + * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace + * - ESPI namespace (http://naesb.org/espi) - with "espi:" prefix + */ +@Service("electricPowerQualitySummaryExportService") +public class ElectricPowerQualitySummaryExportService extends BaseExportService { + + private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"; + private static final String ESPI_NAMESPACE = "http://naesb.org/espi"; + + /** + * Creates JAXBContext with Atom + ElectricPowerQualitySummary domain classes. + * + * @return JAXBContext configured for ElectricPowerQualitySummary resource + * @throws JAXBException if context creation fails + */ + @Override + protected JAXBContext createJAXBContext() throws JAXBException { + return JAXBContext.newInstance( + // Atom protocol classes + org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class, + org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class, + org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto.class, + + // ElectricPowerQualitySummary resource class (http://naesb.org/espi) + org.greenbuttonalliance.espi.common.dto.usage.ElectricPowerQualitySummaryDto.class + ); + } + + /** + * Returns the 2 namespaces for ElectricPowerQualitySummary domain. + * + * @return set containing Atom and ESPI namespaces + */ + @Override + protected Set getDomainNamespaces() { + return Set.of(ATOM_NAMESPACE, ESPI_NAMESPACE); + } +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/IntervalBlockExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/IntervalBlockExportService.java new file mode 100644 index 00000000..ab5fbd59 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/IntervalBlockExportService.java @@ -0,0 +1,74 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.service.impl; + +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import org.greenbuttonalliance.espi.common.service.BaseExportService; +import org.springframework.stereotype.Service; + +import java.util.Set; + +/** + * Export service for ESPI IntervalBlock resource. + *

+ * This service handles XML marshalling for the IntervalBlock resource defined in usage.xsd. + *

+ * Namespace configuration: + * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace + * - ESPI namespace (http://naesb.org/espi) - with "espi:" prefix + */ +@Service("intervalBlockExportService") +public class IntervalBlockExportService extends BaseExportService { + + private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"; + private static final String ESPI_NAMESPACE = "http://naesb.org/espi"; + + /** + * Creates JAXBContext with Atom + IntervalBlock domain classes. + * + * @return JAXBContext configured for IntervalBlock resource + * @throws JAXBException if context creation fails + */ + @Override + protected JAXBContext createJAXBContext() throws JAXBException { + return JAXBContext.newInstance( + // Atom protocol classes + org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class, + org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class, + org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto.class, + + // IntervalBlock resource class (http://naesb.org/espi) + org.greenbuttonalliance.espi.common.dto.usage.IntervalBlockDto.class, + org.greenbuttonalliance.espi.common.dto.usage.IntervalReadingDto.class, + org.greenbuttonalliance.espi.common.dto.usage.ReadingQualityDto.class + ); + } + + /** + * Returns the 2 namespaces for IntervalBlock domain. + * + * @return set containing Atom and ESPI namespaces + */ + @Override + protected Set getDomainNamespaces() { + return Set.of(ATOM_NAMESPACE, ESPI_NAMESPACE); + } +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/MeterReadingExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/MeterReadingExportService.java new file mode 100644 index 00000000..2bd9bdf5 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/MeterReadingExportService.java @@ -0,0 +1,72 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.service.impl; + +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import org.greenbuttonalliance.espi.common.service.BaseExportService; +import org.springframework.stereotype.Service; + +import java.util.Set; + +/** + * Export service for ESPI MeterReading resource. + *

+ * This service handles XML marshalling for the MeterReading resource defined in usage.xsd. + *

+ * Namespace configuration: + * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace + * - ESPI namespace (http://naesb.org/espi) - with "espi:" prefix + */ +@Service("meterReadingExportService") +public class MeterReadingExportService extends BaseExportService { + + private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"; + private static final String ESPI_NAMESPACE = "http://naesb.org/espi"; + + /** + * Creates JAXBContext with Atom + MeterReading domain classes. + * + * @return JAXBContext configured for MeterReading resource + * @throws JAXBException if context creation fails + */ + @Override + protected JAXBContext createJAXBContext() throws JAXBException { + return JAXBContext.newInstance( + // Atom protocol classes + org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class, + org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class, + org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto.class, + + // MeterReading resource class (http://naesb.org/espi) + org.greenbuttonalliance.espi.common.dto.usage.MeterReadingDto.class + ); + } + + /** + * Returns the 2 namespaces for MeterReading domain. + * + * @return set containing Atom and ESPI namespaces + */ + @Override + protected Set getDomainNamespaces() { + return Set.of(ATOM_NAMESPACE, ESPI_NAMESPACE); + } +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ReadingTypeExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ReadingTypeExportService.java new file mode 100644 index 00000000..74aa5a06 --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/ReadingTypeExportService.java @@ -0,0 +1,72 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.service.impl; + +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import org.greenbuttonalliance.espi.common.service.BaseExportService; +import org.springframework.stereotype.Service; + +import java.util.Set; + +/** + * Export service for ESPI ReadingType resource. + *

+ * This service handles XML marshalling for the ReadingType resource defined in usage.xsd. + *

+ * Namespace configuration: + * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace + * - ESPI namespace (http://naesb.org/espi) - with "espi:" prefix + */ +@Service("readingTypeExportService") +public class ReadingTypeExportService extends BaseExportService { + + private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"; + private static final String ESPI_NAMESPACE = "http://naesb.org/espi"; + + /** + * Creates JAXBContext with Atom + ReadingType domain classes. + * + * @return JAXBContext configured for ReadingType resource + * @throws JAXBException if context creation fails + */ + @Override + protected JAXBContext createJAXBContext() throws JAXBException { + return JAXBContext.newInstance( + // Atom protocol classes + org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class, + org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class, + org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto.class, + + // ReadingType resource class (http://naesb.org/espi) + org.greenbuttonalliance.espi.common.dto.usage.ReadingTypeDto.class + ); + } + + /** + * Returns the 2 namespaces for ReadingType domain. + * + * @return set containing Atom and ESPI namespaces + */ + @Override + protected Set getDomainNamespaces() { + return Set.of(ATOM_NAMESPACE, ESPI_NAMESPACE); + } +} diff --git a/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsageSummaryExportService.java b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsageSummaryExportService.java new file mode 100644 index 00000000..e8eb799a --- /dev/null +++ b/openespi-common/src/main/java/org/greenbuttonalliance/espi/common/service/impl/UsageSummaryExportService.java @@ -0,0 +1,72 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.service.impl; + +import jakarta.xml.bind.JAXBContext; +import jakarta.xml.bind.JAXBException; +import org.greenbuttonalliance.espi.common.service.BaseExportService; +import org.springframework.stereotype.Service; + +import java.util.Set; + +/** + * Export service for ESPI UsageSummary resource. + *

+ * This service handles XML marshalling for the UsageSummary resource defined in usage.xsd. + *

+ * Namespace configuration: + * - Atom namespace (http://www.w3.org/2005/Atom) - default namespace + * - ESPI namespace (http://naesb.org/espi) - with "espi:" prefix + */ +@Service("usageSummaryExportService") +public class UsageSummaryExportService extends BaseExportService { + + private static final String ATOM_NAMESPACE = "http://www.w3.org/2005/Atom"; + private static final String ESPI_NAMESPACE = "http://naesb.org/espi"; + + /** + * Creates JAXBContext with Atom + UsageSummary domain classes. + * + * @return JAXBContext configured for UsageSummary resource + * @throws JAXBException if context creation fails + */ + @Override + protected JAXBContext createJAXBContext() throws JAXBException { + return JAXBContext.newInstance( + // Atom protocol classes + org.greenbuttonalliance.espi.common.dto.atom.AtomFeedDto.class, + org.greenbuttonalliance.espi.common.dto.atom.LinkDto.class, + org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto.class, + + // UsageSummary resource class (http://naesb.org/espi) + org.greenbuttonalliance.espi.common.dto.usage.UsageSummaryDto.class + ); + } + + /** + * Returns the 2 namespaces for UsageSummary domain. + * + * @return set containing Atom and ESPI namespaces + */ + @Override + protected Set getDomainNamespaces() { + return Set.of(ATOM_NAMESPACE, ESPI_NAMESPACE); + } +} diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationExportServiceTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationExportServiceTest.java new file mode 100644 index 00000000..95ba6e81 --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/ApplicationInformationExportServiceTest.java @@ -0,0 +1,62 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + * + */ + +package org.greenbuttonalliance.espi.common.service.impl; + +import org.greenbuttonalliance.espi.common.dto.usage.ApplicationInformationDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; + +import static org.junit.jupiter.api.Assertions.assertNotNull; +import static org.junit.jupiter.api.Assertions.assertTrue; + +@DisplayName("ApplicationInformationExportService Unit Tests") +public class ApplicationInformationExportServiceTest { + + private ApplicationInformationExportService exportService; + + @BeforeEach + void setUp() { + exportService = new ApplicationInformationExportService(); + exportService.init(); + } + + @Test + @DisplayName("Export ApplicationInformationDto to XML") + void exportDto_success() { + ApplicationInformationDto dto = new ApplicationInformationDto(); + dto.setClientId("test-client-id"); + dto.setClientName("Test Application"); + dto.setDataCustodianId("test-dc-id"); + + ByteArrayOutputStream baos = new ByteArrayOutputStream(); + exportService.exportDto(dto, baos); + + String xml = baos.toString(); + assertNotNull(xml); + assertTrue(xml.contains("test-client-id")); + assertTrue(xml.contains("Test Application")); + assertTrue(xml.contains("test-dc-id")); + } +} diff --git a/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/MeterReadingExportServiceTest.java b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/MeterReadingExportServiceTest.java new file mode 100644 index 00000000..1aa14cf5 --- /dev/null +++ b/openespi-common/src/test/java/org/greenbuttonalliance/espi/common/service/impl/MeterReadingExportServiceTest.java @@ -0,0 +1,80 @@ +/* + * + * Copyright (c) 2025 Green Button Alliance, Inc. + * + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.common.service.impl; + +import org.greenbuttonalliance.espi.common.dto.atom.UsageAtomEntryDto; +import org.greenbuttonalliance.espi.common.dto.usage.MeterReadingDto; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.DisplayName; +import org.junit.jupiter.api.Test; + +import java.io.ByteArrayOutputStream; + +import static org.assertj.core.api.Assertions.assertThat; + +@DisplayName("MeterReadingExportService Namespace Tests") +class MeterReadingExportServiceTest { + + private MeterReadingExportService meterReadingExportService; + + @BeforeEach + void setUp() { + meterReadingExportService = new MeterReadingExportService(); + meterReadingExportService.init(); + } + + @Test + @DisplayName("Should declare ONLY espi namespace (NOT customer namespace)") + void shouldDeclareEspiNamespaceOnly() { + // Arrange + MeterReadingDto meterReading = new MeterReadingDto(); + UsageAtomEntryDto entry = new UsageAtomEntryDto( + "urn:uuid:550e8400-e29b-51d4-a716-446655440011", + "Meter Reading Test", + meterReading + ); + + // Act + ByteArrayOutputStream stream = new ByteArrayOutputStream(); + meterReadingExportService.exportDto(entry, stream); + String xml = stream.toString(); + + // Assert - ESPI namespace PRESENT + assertThat(xml) + .as("XML should declare espi namespace") + .contains("xmlns:espi=\"http://naesb.org/espi\""); + + // Assert - Customer namespace ABSENT + assertThat(xml) + .as("XML should NOT declare customer namespace") + .doesNotContain("xmlns:cust") + .doesNotContain("http://naesb.org/espi/customer"); + + // Assert - Atom namespace is declared with atom prefix + assertThat(xml) + .as("XML should declare Atom namespace with atom prefix") + .contains("xmlns:atom=\"http://www.w3.org/2005/Atom\""); + + // Assert - MeterReading content with espi prefix + assertThat(xml) + .as("MeterReading should use espi prefix") + .contains(""); + assertThat(xml).contains("USD"); + } +} diff --git a/openespi-datacustodian/controll-update-status.md b/openespi-datacustodian/controll-update-status.md new file mode 100644 index 00000000..41cc188f --- /dev/null +++ b/openespi-datacustodian/controll-update-status.md @@ -0,0 +1,49 @@ +Status of Controller Migration + +- Application Information - completed +- Customer Information - completed +- Customer Account - completed +- Electric Power Quality Summary - completed +- Interval Block - completed +- Meter Reading - completed +- Usage Point - completed +- Reading Type - completed +- Usage Point - completed +- Usage Summary - completed + +Generally where the controller implement subscription or retail customer queries, stubbed out implementations added +until mapping is complete. + +Customer APIs not documented in the API spec, so not sure what the expected payloads are. + +Open Issues: +- Batch Controller - not clear what the expected payloads are. +- Local Time Parameters - not clear what the expected payloads are. +- Service Status - not clear what the expected payloads are. +- Retail Customer - not clear what the expected payloads are. +- Time Configuration - not clear what the expected payloads are, or required functionality. +- Customer controllers had reference to `@accountSecurityService.hasAccessToAccount(authentication, #customerAccountId)` + I was unable to find the implementation of this method. + +Next Steps: +- Finish Remaining controllers. +- Consolidate SQL Migration Scripts. +- Complete subscription and retail customer functionality. +- Add Integration Tests for Postgres and MySQL. + - Will need to load realistic test data. +- Improve testing of returned payloads. + +--- + +Prompt to migrate the controllers to use the new design patterns: + +Inspect the controllers UseagePointController, MeterReadingController, and ReadingTypeRESTController and their +corresponding tests. These classes implement best practices for returning the required payloads. Note +usage of the StreamingResponseBody. + +Your task is to implement the same functionality in the RetailCustomerRESTController to return CustomerDto. +The reference controllers use type specific implementations of the BaseExportService to process the response to the proper +XML format. To complete this task, you will need to implement the BaseExportService for the CustomerDto. + +The RetailCustomerRESTController is legacy code which needs to be refactored to support the new +design patterns. Update RetailCustomerRESTController to use the new design patterns and add proper test coverage. diff --git a/openespi-datacustodian/pom.xml b/openespi-datacustodian/pom.xml index d4b94e52..83e7425c 100644 --- a/openespi-datacustodian/pom.xml +++ b/openespi-datacustodian/pom.xml @@ -149,6 +149,10 @@ org.springframework.boot spring-boot-starter-security + + org.springframework.boot + spring-boot-starter-security-oauth2-authorization-server + org.springframework.boot spring-boot-starter-security-oauth2-resource-server @@ -177,6 +181,12 @@ org.springframework.boot spring-boot-starter-cache + + com.sun.syndication + com.springsource.com.sun.syndication + 1.0.0 + compile + @@ -224,6 +234,13 @@ jaxb-runtime + + + org.projectlombok + lombok + true + + io.micrometer @@ -268,12 +285,17 @@ org.springframework.boot - spring-boot-starter-security-oauth2-resource-server-test + spring-boot-starter-security-test test org.springframework.boot - spring-boot-starter-security-test + spring-boot-starter-security-oauth2-authorization-server-test + test + + + org.springframework.boot + spring-boot-starter-security-oauth2-resource-server-test test diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/AuthorizationServerConfig.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/AuthorizationServerConfig.java new file mode 100644 index 00000000..88ebc311 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/AuthorizationServerConfig.java @@ -0,0 +1,303 @@ +/* + * + * Copyright (c) 2018-2025 Green Button Alliance, Inc. + * + * Portions (c) 2013-2018 EnergyOS.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.authserver.config; + +import org.greenbuttonalliance.espi.datacustodian.authserver.repository.JdbcRegisteredClientRepository; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Primary; +import org.springframework.core.Ordered; +import org.springframework.core.annotation.Order; +import org.springframework.http.MediaType; +import org.springframework.jdbc.core.JdbcTemplate; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.config.annotation.web.configurers.oauth2.server.authorization.OAuth2AuthorizationServerConfigurer; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.core.oidc.OidcScopes; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.JdbcOAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationConsentService; +import org.springframework.security.oauth2.server.authorization.OAuth2AuthorizationService; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.settings.AuthorizationServerSettings; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; +import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; +import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.LoginUrlAuthenticationEntryPoint; +import org.springframework.security.web.header.writers.ReferrerPolicyHeaderWriter; +import org.springframework.security.web.util.matcher.MediaTypeRequestMatcher; + +import java.time.Duration; +import java.time.Instant; +import java.util.UUID; + +/** + * OAuth2 Authorization Server Configuration for OpenESPI + *

+ * Configures Spring Authorization Server 1.3+ for ESPI Green Button Alliance protocol: + * - OAuth2 authorization flows (authorization_code, client_credentials, refresh_token) + * - JWT token settings with ESPI-compliant scopes + * - Client registration for DataCustodian and ThirdParty applications + * - JWK source for JWT signing and validation + * + * @author Green Button Alliance + * @version 1.0.0 + * @since Spring Boot 3.5 + */ +@Configuration(proxyBeanMethods = false) +@EnableWebSecurity +public class AuthorizationServerConfig { + + @Value("${espi.security.require-https:false}") + private boolean requireHttps; + + @Value("${spring.security.oauth2.authorizationserver.issuer:http://localhost:9999}") + private String issuerUri; + + @Value("${oauth2.client.defaults.redirect-uri-base:http://localhost}") + private String defaultRedirectUriBase; + + @Value("${espi.authorization-server.introspection-endpoint:http://localhost:8080/oauth2/introspect}") + private String introspectionUri; + + @Value("${espi.authorization-server.client-id:datacustodian}") + private String clientId; + + @Value("${espi.authorization-server.client-secret:datacustodian-secret}") + private String clientSecret; + + @Value("${spring.security.oauth2.resourceserver.jwt.issuer-uri:}") + private String jwtIssuerUri; + + @Value("${spring.security.oauth2.resourceserver.jwt.jwk-set-uri:}") + private String jwtJwkSetUri; + + + /** + * OAuth2 Authorization Server Security Filter Chain + *

+ * Configures the authorization server endpoints and security: + * - /oauth2/authorize (authorization endpoint) + * - /oauth2/token (token endpoint) + * - /oauth2/jwks (JWK Set endpoint) + * - /.well-known/oauth-authorization-server (discovery endpoint) + */ + @Bean + @Order(Ordered.HIGHEST_PRECEDENCE) + public SecurityFilterChain authorizationServerSecurityFilterChain(HttpSecurity http) throws Exception { + OAuth2AuthorizationServerConfigurer authorizationServer = new OAuth2AuthorizationServerConfigurer(); + http.with(authorizationServer, authServer -> { + if (isJwtResourceServerConfigured()) { + authServer.oidc(Customizer.withDefaults()); + } + }); + + http + .securityMatcher(authorizationServer.getEndpointsMatcher()) + .authorizeHttpRequests((authorize) -> authorize + .requestMatchers("/assets/**", "/webjars/**", "/login").permitAll()) + .formLogin(Customizer.withDefaults()) + // Accept access tokens for User Info and/or Client Registration + .oauth2ResourceServer(resourceServer -> { + if (isJwtResourceServerConfigured()) { + resourceServer.jwt(Customizer.withDefaults()); + } else { + resourceServer.opaqueToken(Customizer.withDefaults()); + } + }) + .csrf(Customizer.withDefaults()) + // Redirect to the login page when not authenticated from the authorization endpoint + .exceptionHandling(exceptions -> exceptions + .defaultAuthenticationEntryPointFor( + new LoginUrlAuthenticationEntryPoint("/login"), + new MediaTypeRequestMatcher(MediaType.TEXT_HTML) + ) + ) + + // HTTPS Channel Security for Production + //should be able to use property server.ssl.enabled=true + //todo - test this +// .requiresChannel(channel -> { +// if (requireHttps) { +// channel.anyRequest().requiresSecure(); +// } +// }) + // Enhanced Security Headers for ESPI Compliance + .headers(headers -> headers + .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny) + .contentTypeOptions(Customizer.withDefaults()) + .httpStrictTransportSecurity(hsts -> hsts + .maxAgeInSeconds(31536000) + .includeSubDomains(true) + .preload(true) + ) + .referrerPolicy(referrer -> referrer + .policy(ReferrerPolicyHeaderWriter.ReferrerPolicy.STRICT_ORIGIN_WHEN_CROSS_ORIGIN) + ) + ) + .sessionManagement(session -> session + .sessionCreationPolicy(org.springframework.security.config.http.SessionCreationPolicy.IF_REQUIRED) + .maximumSessions(1) + .maxSessionsPreventsLogin(false) + ); + + return http.build(); + } + + private boolean isJwtResourceServerConfigured() { + return (jwtIssuerUri != null && !jwtIssuerUri.isBlank()) + || (jwtJwkSetUri != null && !jwtJwkSetUri.isBlank()); + } + + /** + * Registered Client Repository + *

+ * JDBC-backed repository for OAuth2 client registrations with support for: + * - Dynamic client registration + * - ESPI-specific client management + * - Database persistence + * - Client CRUD operations + */ + @Bean + @Primary + public RegisteredClientRepository registeredClientRepository(JdbcTemplate jdbcTemplate, PasswordEncoder passwordEncoder) { + JdbcRegisteredClientRepository repository = new JdbcRegisteredClientRepository(jdbcTemplate, passwordEncoder); + + // Initialize with default ESPI clients if they don't exist + // DataCustodian Admin Client (ROLE_DC_ADMIN) + RegisteredClient datacustodianAdmin = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("data_custodian_admin") + .clientSecret("{bcrypt}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scope("DataCustodian_Admin_Access") + .clientIdIssuedAt(Instant.now()) + .tokenSettings(TokenSettings.builder() + .accessTokenTimeToLive(Duration.ofMinutes(60)) + .accessTokenFormat(OAuth2TokenFormat.REFERENCE) // ESPI standard: opaque tokens + .build()) + .clientSettings(ClientSettings.builder() + .requireAuthorizationConsent(false) + .build()) + .build(); + + // ThirdParty Client (ROLE_USER) - Environment-aware redirect URIs + RegisteredClient thirdPartyClient = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("third_party") + .clientSecret("{bcrypt}secret") + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE) + .authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN) + .clientIdIssuedAt(Instant.now()) + .redirectUri(defaultRedirectUriBase + ":8080/DataCustodian/oauth/callback") + .redirectUri(defaultRedirectUriBase + ":9090/ThirdParty/oauth/callback") + .postLogoutRedirectUri(defaultRedirectUriBase + ":8080/") + .scope(OidcScopes.OPENID) + .scope(OidcScopes.PROFILE) + .scope("FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13") + .scope("FB=4_5_15;IntervalDuration=900;BlockDuration=monthly;HistoryLength=13") + .tokenSettings(TokenSettings.builder() + .accessTokenTimeToLive(Duration.ofMinutes(360)) + .refreshTokenTimeToLive(Duration.ofMinutes(3600)) + .reuseRefreshTokens(true) + .accessTokenFormat(OAuth2TokenFormat.REFERENCE) // ESPI standard: opaque tokens + .build()) + .clientSettings(ClientSettings.builder() + .requireAuthorizationConsent(true) + .build()) + .build(); + + // ThirdParty Admin Client (ROLE_TP_ADMIN) + RegisteredClient thirdPartyAdmin = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId("third_party_admin") + .clientSecret("{bcrypt}secret") + .clientIdIssuedAt(Instant.now()) + .clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC) + .authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS) + .scope("ThirdParty_Admin_Access") + .tokenSettings(TokenSettings.builder() + .accessTokenTimeToLive(Duration.ofMinutes(360)) + .accessTokenFormat(OAuth2TokenFormat.REFERENCE) // ESPI standard: opaque tokens + .build()) + .clientSettings(ClientSettings.builder() + .requireAuthorizationConsent(false) + .build()) + .build(); + + // Initialize default clients if they don't exist + initializeDefaultClients(repository, datacustodianAdmin, thirdPartyClient, thirdPartyAdmin); + + return repository; + } + + /** + * Initialize default ESPI clients if they don't exist in the database + */ + private void initializeDefaultClients(JdbcRegisteredClientRepository repository, + RegisteredClient... clients) { + for (RegisteredClient client : clients) { + if (repository.findByClientId(client.getClientId()) == null) { + repository.save(client); + } + } + + var savedClients = repository.findAll(); + System.out.println("Default ESPI Clients: " + savedClients.size()); + } + + @Bean + public OAuth2AuthorizationService authorizationService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { + return new JdbcOAuth2AuthorizationService(jdbcTemplate, registeredClientRepository); + } + + @Bean + public OAuth2AuthorizationConsentService authorizationConsentService(JdbcTemplate jdbcTemplate, RegisteredClientRepository registeredClientRepository) { + return new JdbcOAuth2AuthorizationConsentService(jdbcTemplate, registeredClientRepository); + } + + /** + * Authorization Server Settings + *

+ * Configures OAuth2 endpoint URLs and issuer + */ + @Bean + public AuthorizationServerSettings authorizationServerSettings() { + return AuthorizationServerSettings.builder() + .issuer(issuerUri) + .authorizationEndpoint("/oauth2/authorize") + .tokenEndpoint("/oauth2/token") + .jwkSetEndpoint("/oauth2/jwks") + .tokenRevocationEndpoint("/oauth2/revoke") + .tokenIntrospectionEndpoint("/oauth2/introspect") + .oidcClientRegistrationEndpoint("/connect/register") + .oidcUserInfoEndpoint("/userinfo") + + .build(); + } +} diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/ClientCertificateAuthenticationConfig.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/ClientCertificateAuthenticationConfig.java new file mode 100644 index 00000000..7d6c2ac1 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/ClientCertificateAuthenticationConfig.java @@ -0,0 +1,260 @@ +/* + * + * Copyright (c) 2018-2025 Green Button Alliance, Inc. + * + * Portions (c) 2013-2018 EnergyOS.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.authserver.config; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.greenbuttonalliance.espi.datacustodian.authserver.service.ClientCertificateService; +import org.greenbuttonalliance.espi.datacustodian.authserver.service.ClientCertificateUserDetailsService; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration; +import org.springframework.security.web.authentication.preauth.PreAuthenticatedAuthenticationProvider; +import org.springframework.security.web.authentication.preauth.x509.SubjectDnX509PrincipalExtractor; +import org.springframework.security.web.authentication.preauth.x509.X509AuthenticationFilter; +import org.springframework.validation.annotation.Validated; + +import java.util.List; + +/** + * Configuration for certificate-based client authentication + * + * Implements NAESB ESPI 4.0 certificate-based authentication: + * - X.509 client certificate validation + * - Certificate revocation checking (CRL/OCSP) + * - Certificate chain validation + * - Subject Distinguished Name (DN) mapping + * - Certificate authority trust store management + * - ESPI-specific certificate extensions + * + * Features: + * - Support for mutual TLS (mTLS) authentication + * - Certificate-based OAuth2 client authentication + * - Integration with ESPI compliance requirements + * - Flexible trust store configuration + * - Certificate renewal and rotation support + * + * @author Green Button Alliance + * @version 1.0.0 + * @since Spring Boot 3.5 + */ +@Configuration +@ConfigurationProperties(prefix = "espi.security.certificate") +@Validated +public class ClientCertificateAuthenticationConfig { + + @NotNull + private boolean enableCertificateAuthentication = false; + + @NotNull + private boolean requireClientCertificate = false; + + @NotNull + private boolean enableCertificateRevocationCheck = true; + + @NotNull + private boolean enableOcspCheck = true; + + @NotNull + private boolean enableCrlCheck = true; + + @NotBlank + private String trustStorePath = "classpath:certificates/truststore.jks"; + + private String trustStorePassword = ""; + + @NotBlank + private String trustStoreType = "JKS"; + + @NotNull + @Min(1) + @Max(365) + private Integer certificateValidityDays = 30; + + @NotNull + @Min(1) + @Max(90) + private Integer certificateRenewalWarningDays = 7; + + @NotNull + @Min(3600) + @Max(86400) + private Integer certificateCacheExpiration = 3600; // 1 hour + + private List trustedCertificateAuthorities; + private List allowedCertificateExtensions; + private List requiredCertificateExtensions; + + /** + * X.509 Authentication Filter for client certificate authentication + */ + @Bean + public X509AuthenticationFilter x509AuthenticationFilter( + AuthenticationConfiguration authConfig, + ClientCertificateUserDetailsService userDetailsService) throws Exception { + + X509AuthenticationFilter filter = new X509AuthenticationFilter(); + filter.setContinueFilterChainOnUnsuccessfulAuthentication(true); + filter.setAuthenticationManager(authConfig.getAuthenticationManager()); + filter.setPrincipalExtractor(new SubjectDnX509PrincipalExtractor()); + + return filter; + } + + /** + * Pre-authenticated authentication provider for certificate authentication + */ + @Bean + public PreAuthenticatedAuthenticationProvider preAuthenticatedAuthenticationProvider( + ClientCertificateUserDetailsService userDetailsService) { + + PreAuthenticatedAuthenticationProvider provider = new PreAuthenticatedAuthenticationProvider(); + provider.setPreAuthenticatedUserDetailsService(userDetailsService); + provider.setThrowExceptionWhenTokenRejected(false); + + return provider; + } + + /** + * Certificate-based user details service + */ + @Bean + public ClientCertificateUserDetailsService clientCertificateUserDetailsService( + ClientCertificateService certificateService) { + return new ClientCertificateUserDetailsService(certificateService); + } + + // Getters and setters + public boolean isEnableCertificateAuthentication() { + return enableCertificateAuthentication; + } + + public void setEnableCertificateAuthentication(boolean enableCertificateAuthentication) { + this.enableCertificateAuthentication = enableCertificateAuthentication; + } + + public boolean isRequireClientCertificate() { + return requireClientCertificate; + } + + public void setRequireClientCertificate(boolean requireClientCertificate) { + this.requireClientCertificate = requireClientCertificate; + } + + public boolean isEnableCertificateRevocationCheck() { + return enableCertificateRevocationCheck; + } + + public void setEnableCertificateRevocationCheck(boolean enableCertificateRevocationCheck) { + this.enableCertificateRevocationCheck = enableCertificateRevocationCheck; + } + + public boolean isEnableOcspCheck() { + return enableOcspCheck; + } + + public void setEnableOcspCheck(boolean enableOcspCheck) { + this.enableOcspCheck = enableOcspCheck; + } + + public boolean isEnableCrlCheck() { + return enableCrlCheck; + } + + public void setEnableCrlCheck(boolean enableCrlCheck) { + this.enableCrlCheck = enableCrlCheck; + } + + public String getTrustStorePath() { + return trustStorePath; + } + + public void setTrustStorePath(String trustStorePath) { + this.trustStorePath = trustStorePath; + } + + public String getTrustStorePassword() { + return trustStorePassword; + } + + public void setTrustStorePassword(String trustStorePassword) { + this.trustStorePassword = trustStorePassword; + } + + public String getTrustStoreType() { + return trustStoreType; + } + + public void setTrustStoreType(String trustStoreType) { + this.trustStoreType = trustStoreType; + } + + public Integer getCertificateValidityDays() { + return certificateValidityDays; + } + + public void setCertificateValidityDays(Integer certificateValidityDays) { + this.certificateValidityDays = certificateValidityDays; + } + + public Integer getCertificateRenewalWarningDays() { + return certificateRenewalWarningDays; + } + + public void setCertificateRenewalWarningDays(Integer certificateRenewalWarningDays) { + this.certificateRenewalWarningDays = certificateRenewalWarningDays; + } + + public Integer getCertificateCacheExpiration() { + return certificateCacheExpiration; + } + + public void setCertificateCacheExpiration(Integer certificateCacheExpiration) { + this.certificateCacheExpiration = certificateCacheExpiration; + } + + public List getTrustedCertificateAuthorities() { + return trustedCertificateAuthorities; + } + + public void setTrustedCertificateAuthorities(List trustedCertificateAuthorities) { + this.trustedCertificateAuthorities = trustedCertificateAuthorities; + } + + public List getAllowedCertificateExtensions() { + return allowedCertificateExtensions; + } + + public void setAllowedCertificateExtensions(List allowedCertificateExtensions) { + this.allowedCertificateExtensions = allowedCertificateExtensions; + } + + public List getRequiredCertificateExtensions() { + return requiredCertificateExtensions; + } + + public void setRequiredCertificateExtensions(List requiredCertificateExtensions) { + this.requiredCertificateExtensions = requiredCertificateExtensions; + } +} \ No newline at end of file diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/DataCustodianIntegrationConfig.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/DataCustodianIntegrationConfig.java new file mode 100644 index 00000000..91d26f73 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/DataCustodianIntegrationConfig.java @@ -0,0 +1,238 @@ +/* + * + * Copyright (c) 2018-2025 Green Button Alliance, Inc. + * + * Portions (c) 2013-2018 EnergyOS.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.authserver.config; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.NotNull; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.http.client.ClientHttpRequestFactory; +import org.springframework.http.client.SimpleClientHttpRequestFactory; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.client.RestTemplate; + +/** + * Configuration for DataCustodian integration + *

+ * Configures: + * - HTTP client settings for DataCustodian communication + * - Connection timeouts and retry policies + * - Authentication credentials + * - Health check intervals + * - Synchronization settings + * + * @author Green Button Alliance + * @version 1.0.0 + * @since Spring Boot 3.5 + */ +@Configuration +@ConfigurationProperties(prefix = "espi.datacustodian") +@Validated +public class DataCustodianIntegrationConfig { + + @NotBlank + private String baseUrl = "http://localhost:8080/DataCustodian"; + + @NotBlank + private String adminClientId = "data_custodian_admin"; + + private String adminClientSecret = ""; + + @NotNull + @Min(1000) + @Max(60000) + private Integer connectionTimeout = 5000; + + @NotNull + @Min(1000) + @Max(300000) + private Integer readTimeout = 10000; + + @NotNull + @Min(1) + @Max(10) + private Integer maxRetries = 3; + + @NotNull + @Min(1000) + @Max(60000) + private Integer retryDelay = 2000; + + @NotNull + @Min(30) + @Max(3600) + private Integer healthCheckInterval = 300; // 5 minutes + + @NotNull + @Min(1) + @Max(24) + private Integer healthLogRetentionDays = 24; + + @NotNull + @Min(1) + @Max(24) + private Integer apiLogRetentionDays = 7; + + private boolean enableHealthChecks = true; + private boolean enableApiLogging = true; + private boolean enableRetries = true; + private boolean validateSslCertificates = true; + + /** + * Configure RestTemplate for DataCustodian communication + */ + @Bean("dataCustodianRestTemplate") + public RestTemplate dataCustodianRestTemplate() { + RestTemplate restTemplate = new RestTemplate(); + + // Configure HTTP client factory with timeouts + ClientHttpRequestFactory factory = clientHttpRequestFactory(); + restTemplate.setRequestFactory(factory); + + return restTemplate; + } + + /** + * Configure HTTP client factory with custom timeouts + */ + private ClientHttpRequestFactory clientHttpRequestFactory() { + SimpleClientHttpRequestFactory factory = new SimpleClientHttpRequestFactory(); + factory.setConnectTimeout(connectionTimeout); + factory.setReadTimeout(readTimeout); + return factory; + } + + // Getters and setters + public String getBaseUrl() { + return baseUrl; + } + + public void setBaseUrl(String baseUrl) { + this.baseUrl = baseUrl; + } + + public String getAdminClientId() { + return adminClientId; + } + + public void setAdminClientId(String adminClientId) { + this.adminClientId = adminClientId; + } + + public String getAdminClientSecret() { + return adminClientSecret; + } + + public void setAdminClientSecret(String adminClientSecret) { + this.adminClientSecret = adminClientSecret; + } + + public Integer getConnectionTimeout() { + return connectionTimeout; + } + + public void setConnectionTimeout(Integer connectionTimeout) { + this.connectionTimeout = connectionTimeout; + } + + public Integer getReadTimeout() { + return readTimeout; + } + + public void setReadTimeout(Integer readTimeout) { + this.readTimeout = readTimeout; + } + + public Integer getMaxRetries() { + return maxRetries; + } + + public void setMaxRetries(Integer maxRetries) { + this.maxRetries = maxRetries; + } + + public Integer getRetryDelay() { + return retryDelay; + } + + public void setRetryDelay(Integer retryDelay) { + this.retryDelay = retryDelay; + } + + public Integer getHealthCheckInterval() { + return healthCheckInterval; + } + + public void setHealthCheckInterval(Integer healthCheckInterval) { + this.healthCheckInterval = healthCheckInterval; + } + + public Integer getHealthLogRetentionDays() { + return healthLogRetentionDays; + } + + public void setHealthLogRetentionDays(Integer healthLogRetentionDays) { + this.healthLogRetentionDays = healthLogRetentionDays; + } + + public Integer getApiLogRetentionDays() { + return apiLogRetentionDays; + } + + public void setApiLogRetentionDays(Integer apiLogRetentionDays) { + this.apiLogRetentionDays = apiLogRetentionDays; + } + + public boolean isEnableHealthChecks() { + return enableHealthChecks; + } + + public void setEnableHealthChecks(boolean enableHealthChecks) { + this.enableHealthChecks = enableHealthChecks; + } + + public boolean isEnableApiLogging() { + return enableApiLogging; + } + + public void setEnableApiLogging(boolean enableApiLogging) { + this.enableApiLogging = enableApiLogging; + } + + public boolean isEnableRetries() { + return enableRetries; + } + + public void setEnableRetries(boolean enableRetries) { + this.enableRetries = enableRetries; + } + + public boolean isValidateSslCertificates() { + return validateSslCertificates; + } + + public void setValidateSslCertificates(boolean validateSslCertificates) { + this.validateSslCertificates = validateSslCertificates; + } +} \ No newline at end of file diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/HttpsEnforcementConfig.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/HttpsEnforcementConfig.java new file mode 100644 index 00000000..10b9ebeb --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/HttpsEnforcementConfig.java @@ -0,0 +1,242 @@ +/* + * + * Copyright (c) 2018-2025 Green Button Alliance, Inc. + * + * Portions (c) 2013-2018 EnergyOS.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.authserver.config; + +import jakarta.annotation.PostConstruct; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.boot.tomcat.servlet.TomcatServletWebServerFactory; +import org.springframework.boot.web.server.servlet.ServletWebServerFactory; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.context.annotation.Profile; +import org.springframework.core.annotation.Order; +import org.springframework.security.config.Customizer; +import org.springframework.security.config.annotation.web.builders.HttpSecurity; +import org.springframework.security.config.annotation.web.configurers.HeadersConfigurer; +import org.springframework.security.web.SecurityFilterChain; + +/** + * HTTPS Enforcement Configuration for OpenESPI Authorization Server + *

+ * Provides environment-specific HTTPS enforcement with NAESB ESPI 4.0 compliance: + * - Production: Mandatory TLS 1.3 with ESPI 4.0 approved cipher suites + * - Development: Flexible HTTP/HTTPS for local development + * - Test: HTTP allowed for testing + *

+ * NAESB ESPI 4.0 Requirements: + * - TLS 1.3 ONLY (no TLS 1.2 or earlier) + * - Approved cipher suites: TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_AES_128_GCM_SHA256 + * - Perfect Forward Secrecy (PFS) mandatory + * - Certificate validation required + * + * @author Green Button Alliance + * @version 1.0.0 + * @since Spring Boot 3.5 + */ +@Configuration +public class HttpsEnforcementConfig { + + private static final Logger logger = LoggerFactory.getLogger(HttpsEnforcementConfig.class); + + @Value("${espi.security.require-https:false}") + private boolean requireHttps; + + @Value("${spring.profiles.active:dev}") + private String activeProfile; + + @PostConstruct + public void logSecurityConfiguration() { + logger.info("NAESB ESPI 4.0 Security Configuration:"); + logger.info(" Active Profile: {}", activeProfile); + logger.info(" HTTPS Required: {}", requireHttps); + logger.info(" TLS Version: TLS 1.3 ONLY (NAESB ESPI 4.0 Standard)"); + logger.info(" Cipher Suites: TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_AES_128_GCM_SHA256"); + + if ("prod".equals(activeProfile) && !requireHttps) { + logger.error("CRITICAL: Production profile detected but HTTPS not enforced!"); + logger.error("NAESB ESPI 4.0 REQUIRES TLS 1.3 enforcement in production"); + logger.error("Set espi.security.require-https=true for production deployment"); + throw new IllegalStateException("NAESB ESPI 4.0 requires HTTPS enforcement in production"); + } + + if (requireHttps) { + logger.info("NAESB ESPI 4.0 TLS 1.3 enforcement enabled"); + logger.info(" - All HTTP requests will be redirected to HTTPS"); + logger.info(" - Client redirect URIs must use HTTPS (except localhost for development)"); + logger.info(" - Perfect Forward Secrecy (PFS) enforced"); + logger.info(" - Certificate validation mandatory"); + } else { + logger.info("HTTPS enforcement disabled - HTTP allowed for development/testing only"); + logger.warn("WARNING: This configuration is NOT suitable for production deployment"); + } + } + + /** + * Production HTTPS Security Filter Chain + *

+ * Enforces TLS 1.3 for all requests in production environment (NAESB ESPI 4.0) + */ + @Bean + @Profile("prod") + @Order(0) + public SecurityFilterChain httpsEnforcementFilterChain(HttpSecurity http) throws Exception { + logger.info("Configuring production HTTPS enforcement filter chain"); + + http + .securityMatcher("/**") + //should be able to use property server.ssl.enabled=true + //todo - test this +// .requiresChannel(channel -> +// channel.anyRequest().requiresSecure() +// ) + .headers(headers -> headers + .httpStrictTransportSecurity(hstsConfig -> hstsConfig + .maxAgeInSeconds(31536000) // 1 year + .includeSubDomains(true) + .preload(true) + ) + .frameOptions(HeadersConfigurer.FrameOptionsConfig::deny) + .contentTypeOptions(Customizer.withDefaults()) + .addHeaderWriter((request, response) -> { + // NAESB ESPI 4.0 Enhanced Security Headers + + response.setHeader("Strict-Transport-Security", + "max-age=31536000; includeSubDomains; preload"); + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("X-Frame-Options", "DENY"); + response.setHeader("X-XSS-Protection", "1; mode=block"); + response.setHeader("Referrer-Policy", "strict-origin-when-cross-origin"); + response.setHeader("Content-Security-Policy", + "default-src 'self'; " + + "script-src 'self' 'unsafe-inline'; " + + "style-src 'self' 'unsafe-inline'; " + + "img-src 'self' data:; " + + "font-src 'self'; " + + "connect-src 'self'; " + + "frame-ancestors 'none'"); + // NAESB ESPI 4.0 Compliance Headers + response.setHeader("X-ESPI-Version", "4.0"); + response.setHeader("X-TLS-Version", "TLSv1.3"); + response.setHeader("X-Cipher-Suites", "TLS_AES_256_GCM_SHA384,TLS_CHACHA20_POLY1305_SHA256,TLS_AES_128_GCM_SHA256"); + }) + ); + + return http.build(); + } + + /** + * Development Security Configuration + *

+ * Allows HTTP for development while still providing security headers + */ + @Bean + @Profile({"dev", "dev-mysql", "dev-postgresql", "local"}) + @Order(0) + public SecurityFilterChain developmentSecurityFilterChain(HttpSecurity http) throws Exception { + logger.info("Configuring development security filter chain (HTTP allowed)"); + + http + .securityMatcher("/**") + .headers(headers -> headers + .frameOptions(HeadersConfigurer.FrameOptionsConfig::sameOrigin) // Less restrictive for development + .contentTypeOptions(Customizer.withDefaults()) + .addHeaderWriter((request, response) -> { + // Development-friendly headers + response.setHeader("X-Content-Type-Options", "nosniff"); + response.setHeader("X-Frame-Options", "SAMEORIGIN"); + response.setHeader("X-XSS-Protection", "1; mode=block"); + response.setHeader("Cache-Control", "no-cache, no-store, must-revalidate"); + response.setHeader("Pragma", "no-cache"); + }) + ); + + return http.build(); + } + + /** + * HTTPS Redirect Configuration for Mixed Environments + *

+ * Provides HTTP to HTTPS redirect when HTTPS is available but not enforced + */ + @Bean + @Profile("!test") + public ServletWebServerFactory servletContainer() { + TomcatServletWebServerFactory factory = new TomcatServletWebServerFactory() { + @Override + public void setPort(int port) { + super.setPort(port); + + if (requireHttps && port != 443 && port != 8443) { + logger.warn("HTTPS required but non-standard HTTPS port {} configured", port); + logger.warn("Ensure SSL is properly configured for this port"); + } + } + }; + return factory; + } + + /** + * NAESB ESPI 4.0 Configuration Validation + */ + @PostConstruct + public void validateConfiguration() { + if ("prod".equals(activeProfile)) { + if (!requireHttps) { + throw new IllegalStateException( + "NAESB ESPI 4.0 requires TLS 1.3 enforcement in production. " + + "Set espi.security.require-https=true" + ); + } + logger.info("NAESB ESPI 4.0 TLS 1.3 enforcement validated successfully"); + logger.info("Production deployment meets ESPI 4.0 security requirements"); + } + + if (requireHttps) { + logger.info("NAESB ESPI 4.0 TLS 1.3 enforcement active for profile: {}", activeProfile); + logger.info("Cipher suites restricted to ESPI 4.0 approved algorithms"); + } + + // Validate Java version supports TLS 1.3 + String javaVersion = System.getProperty("java.version"); + logger.info("Java version: {} (TLS 1.3 support required for NAESB ESPI 4.0)", javaVersion); + + if (requireHttps) { + // Additional runtime validation for production + validateTls13Support(); + } + } + + /** + * Validate TLS 1.3 support at runtime + */ + private void validateTls13Support() { + try { + javax.net.ssl.SSLContext sslContext = javax.net.ssl.SSLContext.getInstance("TLSv1.3"); + logger.info("TLS 1.3 support validated - SSLContext available"); + } catch (Exception e) { + logger.error("CRITICAL: TLS 1.3 not supported on this Java runtime"); + logger.error("NAESB ESPI 4.0 requires TLS 1.3 support"); + throw new IllegalStateException("TLS 1.3 support required for NAESB ESPI 4.0 compliance", e); + } + } +} \ No newline at end of file diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/OAuth2ClientManagementConfig.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/OAuth2ClientManagementConfig.java new file mode 100644 index 00000000..68311c98 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/config/OAuth2ClientManagementConfig.java @@ -0,0 +1,154 @@ +/* + * + * Copyright (c) 2018-2025 Green Button Alliance, Inc. + * + * Portions (c) 2013-2018 EnergyOS.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.authserver.config; + +import jakarta.validation.constraints.Max; +import jakarta.validation.constraints.Min; +import jakarta.validation.constraints.NotNull; +import org.springframework.boot.context.properties.ConfigurationProperties; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.scheduling.annotation.EnableScheduling; +import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.validation.annotation.Validated; + +/** + * Configuration for OAuth2 Client Management features + *

+ * Enables and configures: + * - Scheduled tasks for metrics calculation + * - Password encoding for client secrets + * - Client management security settings + * - Rate limiting and session management defaults + * + * @author Green Button Alliance + * @version 1.0.0 + * @since Spring Boot 3.5 + */ +@Configuration +@EnableScheduling +@ConfigurationProperties(prefix = "espi.oauth2.client-management") +@Validated +public class OAuth2ClientManagementConfig { + + @NotNull + @Min(1) + @Max(100) + private Integer defaultRateLimitPerMinute = 100; + + @NotNull + @Min(1) + @Max(1000) + private Integer defaultMaxConcurrentSessions = 5; + + @NotNull + @Min(1) + @Max(100) + private Integer maxFailuresBeforeLock = 10; + + @NotNull + @Min(1) + @Max(43200) // 30 days in minutes + private Integer defaultLockDurationMinutes = 60; + + @NotNull + @Min(1) + @Max(3650) // 10 years + private Integer metricsRetentionDays = 365; + + private boolean enableAutomaticMetricsCalculation = true; + private boolean enableAutomaticCleanup = true; + private boolean enableSecurityMonitoring = true; + + /** + * Password encoder for client secrets + */ + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(12); // Strong password encoding + } + + // Getters and setters + public Integer getDefaultRateLimitPerMinute() { + return defaultRateLimitPerMinute; + } + + public void setDefaultRateLimitPerMinute(Integer defaultRateLimitPerMinute) { + this.defaultRateLimitPerMinute = defaultRateLimitPerMinute; + } + + public Integer getDefaultMaxConcurrentSessions() { + return defaultMaxConcurrentSessions; + } + + public void setDefaultMaxConcurrentSessions(Integer defaultMaxConcurrentSessions) { + this.defaultMaxConcurrentSessions = defaultMaxConcurrentSessions; + } + + public Integer getMaxFailuresBeforeLock() { + return maxFailuresBeforeLock; + } + + public void setMaxFailuresBeforeLock(Integer maxFailuresBeforeLock) { + this.maxFailuresBeforeLock = maxFailuresBeforeLock; + } + + public Integer getDefaultLockDurationMinutes() { + return defaultLockDurationMinutes; + } + + public void setDefaultLockDurationMinutes(Integer defaultLockDurationMinutes) { + this.defaultLockDurationMinutes = defaultLockDurationMinutes; + } + + public Integer getMetricsRetentionDays() { + return metricsRetentionDays; + } + + public void setMetricsRetentionDays(Integer metricsRetentionDays) { + this.metricsRetentionDays = metricsRetentionDays; + } + + public boolean isEnableAutomaticMetricsCalculation() { + return enableAutomaticMetricsCalculation; + } + + public void setEnableAutomaticMetricsCalculation(boolean enableAutomaticMetricsCalculation) { + this.enableAutomaticMetricsCalculation = enableAutomaticMetricsCalculation; + } + + public boolean isEnableAutomaticCleanup() { + return enableAutomaticCleanup; + } + + public void setEnableAutomaticCleanup(boolean enableAutomaticCleanup) { + this.enableAutomaticCleanup = enableAutomaticCleanup; + } + + public boolean isEnableSecurityMonitoring() { + return enableSecurityMonitoring; + } + + public void setEnableSecurityMonitoring(boolean enableSecurityMonitoring) { + this.enableSecurityMonitoring = enableSecurityMonitoring; + } +} \ No newline at end of file diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/CertificateManagementController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/CertificateManagementController.java new file mode 100644 index 00000000..ba9b6ded --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/CertificateManagementController.java @@ -0,0 +1,450 @@ +/* + * + * Copyright (c) 2018-2025 Green Button Alliance, Inc. + * + * Portions (c) 2013-2018 EnergyOS.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.authserver.controller; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import jakarta.validation.Valid; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import org.greenbuttonalliance.espi.datacustodian.authserver.service.ClientCertificateService; +import org.greenbuttonalliance.espi.datacustodian.authserver.service.ClientCertificateService.*; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.access.prepost.PreAuthorize; +import org.springframework.validation.annotation.Validated; +import org.springframework.web.bind.annotation.*; +import org.springframework.web.multipart.MultipartFile; +import java.io.ByteArrayInputStream; +import java.security.cert.CertificateFactory; +import java.security.cert.X509Certificate; +import java.time.Instant; +import java.util.HashMap; +import java.util.List; +import java.util.Map; + +/** + * REST Controller for certificate management operations + *

+ * Provides API endpoints for: + * - Certificate upload and storage + * - Certificate validation and verification + * - Certificate information retrieval + * - Certificate revocation + * - Certificate renewal monitoring + * + * @author Green Button Alliance + * @version 1.0.0 + * @since Spring Boot 3.5 + */ +@RestController +@RequestMapping("/api/v1/certificates") +@PreAuthorize("hasRole('ADMIN') or hasRole('DC_ADMIN')") +@Validated +public class CertificateManagementController { + + private static final Logger logger = LoggerFactory.getLogger(CertificateManagementController.class); + + private final ClientCertificateService certificateService; + + @Autowired + public CertificateManagementController(ClientCertificateService certificateService) { + this.certificateService = certificateService; + } + + /** + * Upload client certificate + *

+ * POST /api/v1/certificates/clients/{clientId}/upload + */ + @PostMapping("/clients/{clientId}/upload") + public ResponseEntity uploadCertificate( + @PathVariable @NotBlank String clientId, + @RequestParam("certificate") MultipartFile certificateFile, + @RequestParam(value = "uploaded_by", required = false) String uploadedBy) { + + logger.info("Uploading certificate for client: {}", clientId); + + try { + if (certificateFile.isEmpty()) { + return ResponseEntity.badRequest() + .body(new ErrorResponse("invalid_file", "Certificate file is empty")); + } + + // Parse certificate + X509Certificate certificate = parseCertificate(certificateFile.getBytes()); + if (certificate == null) { + return ResponseEntity.badRequest() + .body(new ErrorResponse("invalid_certificate", "Unable to parse certificate")); + } + + // Validate certificate + ClientCertificateService.CertificateValidationResult validation = + certificateService.validateClientCertificate(certificate); + + if (!validation.isValid()) { + return ResponseEntity.badRequest() + .body(new ErrorResponse("certificate_validation_failed", validation.getErrorMessage())); + } + + // Store certificate + String uploader = uploadedBy != null ? uploadedBy : "system"; + certificateService.storeClientCertificate(clientId, certificate, uploader); + + // Build response + CertificateUploadResponse response = new CertificateUploadResponse(); + response.setClientId(clientId); + response.setSerialNumber(certificate.getSerialNumber().toString()); + response.setSubjectDn(certificate.getSubjectDN().toString()); + response.setIssuerDn(certificate.getIssuerDN().toString()); + response.setNotBefore(certificate.getNotBefore().toInstant()); + response.setNotAfter(certificate.getNotAfter().toInstant()); + response.setUploadedAt(Instant.now()); + response.setStatus("active"); + + return ResponseEntity.status(HttpStatus.CREATED).body(response); + + } catch (Exception e) { + logger.error("Error uploading certificate for client: {}", clientId, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("upload_failed", "Certificate upload failed")); + } + } + + /** + * Get client certificate information + *

+ * GET /api/v1/certificates/clients/{clientId} + */ + @GetMapping("/clients/{clientId}") + public ResponseEntity getCertificate(@PathVariable @NotBlank String clientId) { + logger.debug("Getting certificate info for client: {}", clientId); + + try { + ClientCertificateService.ClientCertificateInfo certificateInfo = certificateService.getClientCertificateInfo(clientId); + + if (certificateInfo == null) { + return ResponseEntity.notFound().build(); + } + + return ResponseEntity.ok(certificateInfo); + + } catch (Exception e) { + logger.error("Error getting certificate for client: {}", clientId, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("retrieval_failed", "Failed to retrieve certificate")); + } + } + + /** + * Revoke client certificate + *

+ * DELETE /api/v1/certificates/clients/{clientId} + */ + @DeleteMapping("/clients/{clientId}") + public ResponseEntity revokeCertificate( + @PathVariable @NotBlank String clientId, + @Valid @RequestBody RevokeCertificateRequest request) { + + logger.info("Revoking certificate for client: {}, reason: {}", clientId, request.getReason()); + + try { + certificateService.revokeCertificate(clientId, request.getReason(), request.getRevokedBy()); + + Map response = new HashMap<>(); + response.put("status", "revoked"); + response.put("client_id", clientId); + response.put("message", "Certificate revoked successfully"); + response.put("revoked_at", Instant.now().toString()); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + logger.error("Error revoking certificate for client: {}", clientId, e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("revocation_failed", "Failed to revoke certificate")); + } + } + + /** + * Get certificates expiring soon + *

+ * GET /api/v1/certificates/expiring + */ + @GetMapping("/expiring") + public ResponseEntity getExpiringCertificates() { + logger.debug("Getting certificates expiring soon"); + + try { + List expiringCertificates = + certificateService.getCertificatesExpiringSoon(); + + ExpiringCertificatesResponse response = new ExpiringCertificatesResponse(); + response.setCertificates(expiringCertificates); + response.setCount(expiringCertificates.size()); + response.setRetrievedAt(Instant.now()); + + return ResponseEntity.ok(response); + + } catch (Exception e) { + logger.error("Error getting expiring certificates", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("retrieval_failed", "Failed to retrieve expiring certificates")); + } + } + + /** + * Validate certificate format and content + *

+ * POST /api/v1/certificates/validate + */ + @PostMapping("/validate") + public ResponseEntity validateCertificate(@RequestParam("certificate") MultipartFile certificateFile) { + logger.debug("Validating certificate format"); + + try { + if (certificateFile.isEmpty()) { + return ResponseEntity.badRequest() + .body(new ErrorResponse("invalid_file", "Certificate file is empty")); + } + + // Parse certificate + X509Certificate certificate = parseCertificate(certificateFile.getBytes()); + if (certificate == null) { + return ResponseEntity.badRequest() + .body(new ErrorResponse("invalid_certificate", "Unable to parse certificate")); + } + + // Validate certificate + ClientCertificateService.CertificateValidationResult validation = + certificateService.validateClientCertificate(certificate); + + CertificateValidationResponse response = new CertificateValidationResponse(); + response.setValid(validation.isValid()); + response.setSerialNumber(certificate.getSerialNumber().toString()); + response.setSubjectDn(certificate.getSubjectDN().toString()); + response.setIssuerDn(certificate.getIssuerDN().toString()); + response.setNotBefore(certificate.getNotBefore().toInstant()); + response.setNotAfter(certificate.getNotAfter().toInstant()); + response.setValidatedAt(Instant.now()); + + if (!validation.isValid()) { + response.setErrorMessage(validation.getErrorMessage()); + } + + return ResponseEntity.ok(response); + + } catch (Exception e) { + logger.error("Error validating certificate", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("validation_failed", "Certificate validation failed")); + } + } + + // Helper methods + + private X509Certificate parseCertificate(byte[] certificateData) { + try { + CertificateFactory factory = CertificateFactory.getInstance("X.509"); + return (X509Certificate) factory.generateCertificate(new ByteArrayInputStream(certificateData)); + } catch (Exception e) { + logger.error("Error parsing certificate", e); + return null; + } + } + + // DTO Classes + + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class RevokeCertificateRequest { + @NotBlank(message = "Reason is required") + @Size(max = 200, message = "Reason must not exceed 200 characters") + @JsonProperty("reason") + private String reason; + + @JsonProperty("revoked_by") + @Size(max = 100, message = "Revoked by must not exceed 100 characters") + private String revokedBy; + + // Getters and setters + public String getReason() { return reason; } + public void setReason(String reason) { this.reason = reason; } + + public String getRevokedBy() { return revokedBy; } + public void setRevokedBy(String revokedBy) { this.revokedBy = revokedBy; } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class CertificateUploadResponse { + @JsonProperty("client_id") + private String clientId; + + @JsonProperty("serial_number") + private String serialNumber; + + @JsonProperty("subject_dn") + private String subjectDn; + + @JsonProperty("issuer_dn") + private String issuerDn; + + @JsonProperty("not_before") + private Instant notBefore; + + @JsonProperty("not_after") + private Instant notAfter; + + @JsonProperty("uploaded_at") + private Instant uploadedAt; + + @JsonProperty("status") + private String status; + + // Getters and setters + public String getClientId() { return clientId; } + public void setClientId(String clientId) { this.clientId = clientId; } + + public String getSerialNumber() { return serialNumber; } + public void setSerialNumber(String serialNumber) { this.serialNumber = serialNumber; } + + public String getSubjectDn() { return subjectDn; } + public void setSubjectDn(String subjectDn) { this.subjectDn = subjectDn; } + + public String getIssuerDn() { return issuerDn; } + public void setIssuerDn(String issuerDn) { this.issuerDn = issuerDn; } + + public Instant getNotBefore() { return notBefore; } + public void setNotBefore(Instant notBefore) { this.notBefore = notBefore; } + + public Instant getNotAfter() { return notAfter; } + public void setNotAfter(Instant notAfter) { this.notAfter = notAfter; } + + public Instant getUploadedAt() { return uploadedAt; } + public void setUploadedAt(Instant uploadedAt) { this.uploadedAt = uploadedAt; } + + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class CertificateValidationResponse { + @JsonProperty("valid") + private boolean valid; + + @JsonProperty("serial_number") + private String serialNumber; + + @JsonProperty("subject_dn") + private String subjectDn; + + @JsonProperty("issuer_dn") + private String issuerDn; + + @JsonProperty("not_before") + private Instant notBefore; + + @JsonProperty("not_after") + private Instant notAfter; + + @JsonProperty("validated_at") + private Instant validatedAt; + + @JsonProperty("error_message") + private String errorMessage; + + // Getters and setters + public boolean isValid() { return valid; } + public void setValid(boolean valid) { this.valid = valid; } + + public String getSerialNumber() { return serialNumber; } + public void setSerialNumber(String serialNumber) { this.serialNumber = serialNumber; } + + public String getSubjectDn() { return subjectDn; } + public void setSubjectDn(String subjectDn) { this.subjectDn = subjectDn; } + + public String getIssuerDn() { return issuerDn; } + public void setIssuerDn(String issuerDn) { this.issuerDn = issuerDn; } + + public Instant getNotBefore() { return notBefore; } + public void setNotBefore(Instant notBefore) { this.notBefore = notBefore; } + + public Instant getNotAfter() { return notAfter; } + public void setNotAfter(Instant notAfter) { this.notAfter = notAfter; } + + public Instant getValidatedAt() { return validatedAt; } + public void setValidatedAt(Instant validatedAt) { this.validatedAt = validatedAt; } + + public String getErrorMessage() { return errorMessage; } + public void setErrorMessage(String errorMessage) { this.errorMessage = errorMessage; } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ExpiringCertificatesResponse { + @JsonProperty("certificates") + private List certificates; + + @JsonProperty("count") + private int count; + + @JsonProperty("retrieved_at") + private Instant retrievedAt; + + // Getters and setters + public List getCertificates() { return certificates; } + public void setCertificates(List certificates) { this.certificates = certificates; } + + public int getCount() { return count; } + public void setCount(int count) { this.count = count; } + + public Instant getRetrievedAt() { return retrievedAt; } + public void setRetrievedAt(Instant retrievedAt) { this.retrievedAt = retrievedAt; } + } + + @JsonInclude(JsonInclude.Include.NON_NULL) + public static class ErrorResponse { + @JsonProperty("status") + private String status = "error"; + + @JsonProperty("error") + private String error; + + @JsonProperty("message") + private String message; + + public ErrorResponse(String error, String message) { + this.error = error; + this.message = message; + } + + // Getters and setters + public String getStatus() { return status; } + public void setStatus(String status) { this.status = status; } + + public String getError() { return error; } + public void setError(String error) { this.error = error; } + + public String getMessage() { return message; } + public void setMessage(String message) { this.message = message; } + } +} \ No newline at end of file diff --git a/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/ClientRegistrationController.java b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/ClientRegistrationController.java new file mode 100644 index 00000000..b18d8738 --- /dev/null +++ b/openespi-datacustodian/src/main/java/org/greenbuttonalliance/espi/datacustodian/authserver/controller/ClientRegistrationController.java @@ -0,0 +1,580 @@ +/* + * + * Copyright (c) 2018-2025 Green Button Alliance, Inc. + * + * Portions (c) 2013-2018 EnergyOS.org + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + */ + +package org.greenbuttonalliance.espi.datacustodian.authserver.controller; + +import com.fasterxml.jackson.annotation.JsonInclude; +import com.fasterxml.jackson.annotation.JsonProperty; +import org.slf4j.Logger; +import org.slf4j.LoggerFactory; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.security.oauth2.core.AuthorizationGrantType; +import org.springframework.security.oauth2.core.ClientAuthenticationMethod; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClient; +import org.springframework.security.oauth2.server.authorization.client.RegisteredClientRepository; +import org.springframework.security.oauth2.server.authorization.settings.ClientSettings; +import org.springframework.security.oauth2.server.authorization.settings.OAuth2TokenFormat; +import org.springframework.security.oauth2.server.authorization.settings.TokenSettings; +import org.springframework.util.StringUtils; +import org.springframework.web.bind.annotation.*; + +import java.security.SecureRandom; +import java.time.Duration; +import java.time.Instant; +import java.util.*; + +/** + * OIDC Dynamic Client Registration Controller + *

+ * Implements RFC 7591 (OAuth 2.0 Dynamic Client Registration Protocol) with ESPI extensions. + * Provides endpoints for: + * - Dynamic client registration (/connect/register) + * - ESPI-specific client registration validation + * - Green Button Alliance compliance checks + * + * @author Green Button Alliance + * @version 1.0.0 + * @since Spring Boot 3.5 + */ +@RestController +@RequestMapping("/connect") +public class ClientRegistrationController { + + private static final Logger logger = LoggerFactory.getLogger(ClientRegistrationController.class); + + private final RegisteredClientRepository clientRepository; + private final SecureRandom secureRandom = new SecureRandom(); + + @Value("${espi.security.require-https-redirect-uris:false}") + private boolean requireHttpsRedirectUris; + + // ESPI-specific scopes + private static final Set ESPI_SCOPES = Set.of( + "openid", "profile", + "FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13", + "FB=4_5_15;IntervalDuration=900;BlockDuration=monthly;HistoryLength=13", + "DataCustodian_Admin_Access", + "ThirdParty_Admin_Access" + ); + + public ClientRegistrationController(RegisteredClientRepository clientRepository) { + this.clientRepository = clientRepository; + } + + /** + * OIDC Dynamic Client Registration Endpoint + *

+ * POST /connect/register + */ + @PostMapping("/register") + public ResponseEntity registerClient(@RequestBody ClientRegistrationRequest request) { + logger.debug("Processing client registration request for client_name: {}", request.getClientName()); + + try { + // Validate registration request + validateRegistrationRequest(request); + + // Generate client credentials + String clientId = generateClientId(); + String clientSecret = generateClientSecret(); + + // Build registered client + RegisteredClient.Builder clientBuilder = RegisteredClient.withId(UUID.randomUUID().toString()) + .clientId(clientId) + .clientSecret("{noop}" + clientSecret) // TODO: Use proper password encoder + .clientName(request.getClientName()) + .clientIdIssuedAt(Instant.now()); + + // Set client secret expiration if provided + if (request.getClientSecretExpiresAt() != null && request.getClientSecretExpiresAt() > 0) { + clientBuilder.clientSecretExpiresAt(Instant.ofEpochSecond(request.getClientSecretExpiresAt())); + } + + // Configure authentication methods + configureAuthenticationMethods(clientBuilder, request); + + // Configure grant types + configureGrantTypes(clientBuilder, request); + + // Configure redirect URIs + configureRedirectUris(clientBuilder, request); + + // Configure scopes + configureScopes(clientBuilder, request); + + // Configure client and token settings + configureClientSettings(clientBuilder, request); + configureTokenSettings(clientBuilder, request); + + RegisteredClient registeredClient = clientBuilder.build(); + + // Save the client + clientRepository.save(registeredClient); + + logger.info("Successfully registered client: {} with client_id: {}", request.getClientName(), clientId); + + // Return client registration response + ClientRegistrationResponse response = buildRegistrationResponse(registeredClient, clientSecret); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + + } catch (IllegalArgumentException e) { + logger.warn("Client registration validation failed: {}", e.getMessage()); + return ResponseEntity.badRequest().body(new ErrorResponse("invalid_client_metadata", e.getMessage())); + } catch (Exception e) { + logger.error("Client registration failed", e); + return ResponseEntity.status(HttpStatus.INTERNAL_SERVER_ERROR) + .body(new ErrorResponse("server_error", "Internal server error")); + } + } + + /** + * Get registered client information + *

+ * GET /connect/register/{client_id} + */ + @GetMapping("/register/{clientId}") + public ResponseEntity getClient(@PathVariable String clientId) { + logger.debug("Retrieving client information for client_id: {}", clientId); + + RegisteredClient client = clientRepository.findByClientId(clientId); + if (client == null) { + return ResponseEntity.notFound().build(); + } + + ClientRegistrationResponse response = buildRegistrationResponse(client, null); // Don't return secret + return ResponseEntity.ok(response); + } + + /** + * Validate client registration request + */ + private void validateRegistrationRequest(ClientRegistrationRequest request) { + if (!StringUtils.hasText(request.getClientName())) { + throw new IllegalArgumentException("client_name is required"); + } + + if (request.getRedirectUris() == null || request.getRedirectUris().isEmpty()) { + throw new IllegalArgumentException("redirect_uris is required"); + } + + // Validate redirect URIs + for (String uri : request.getRedirectUris()) { + if (!isValidRedirectUri(uri)) { + throw new IllegalArgumentException("Invalid redirect_uri: " + uri); + } + } + + // Validate ESPI-specific requirements + validateEspiRequirements(request); + } + + /** + * Validate ESPI-specific requirements + */ + private void validateEspiRequirements(ClientRegistrationRequest request) { + // Ensure requested scopes are ESPI-compliant + if (request.getScope() != null) { + Set requestedScopes = new HashSet<>(Arrays.asList(request.getScope().split(" "))); + for (String scope : requestedScopes) { + if (!ESPI_SCOPES.contains(scope)) { + logger.warn("Non-ESPI scope requested: {}", scope); + // Note: We don't reject here, just log the warning + } + } + } + + // Validate grant types for ESPI compliance + if (request.getGrantTypes() != null) { + Set allowedGrantTypes = Set.of("authorization_code", "client_credentials", "refresh_token"); + for (String grantType : request.getGrantTypes()) { + if (!allowedGrantTypes.contains(grantType)) { + throw new IllegalArgumentException("Unsupported grant_type for ESPI: " + grantType); + } + } + } + } + + /** + * Configure authentication methods + */ + private void configureAuthenticationMethods(RegisteredClient.Builder builder, ClientRegistrationRequest request) { + if (request.getTokenEndpointAuthMethod() != null) { + switch (request.getTokenEndpointAuthMethod()) { + case "client_secret_basic" -> builder.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC); + case "client_secret_post" -> builder.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_POST); + case "none" -> builder.clientAuthenticationMethod(ClientAuthenticationMethod.NONE); + default -> throw new IllegalArgumentException("Unsupported token_endpoint_auth_method: " + request.getTokenEndpointAuthMethod()); + } + } else { + // Default to client_secret_basic for ESPI + builder.clientAuthenticationMethod(ClientAuthenticationMethod.CLIENT_SECRET_BASIC); + } + } + + /** + * Configure grant types + */ + private void configureGrantTypes(RegisteredClient.Builder builder, ClientRegistrationRequest request) { + if (request.getGrantTypes() != null && !request.getGrantTypes().isEmpty()) { + for (String grantType : request.getGrantTypes()) { + switch (grantType) { + case "authorization_code" -> builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE); + case "client_credentials" -> builder.authorizationGrantType(AuthorizationGrantType.CLIENT_CREDENTIALS); + case "refresh_token" -> builder.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN); + default -> throw new IllegalArgumentException("Unsupported grant_type: " + grantType); + } + } + } else { + // Default to authorization_code for ESPI + builder.authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE); + builder.authorizationGrantType(AuthorizationGrantType.REFRESH_TOKEN); + } + } + + /** + * Configure redirect URIs + */ + private void configureRedirectUris(RegisteredClient.Builder builder, ClientRegistrationRequest request) { + for (String uri : request.getRedirectUris()) { + builder.redirectUri(uri); + } + + if (request.getPostLogoutRedirectUris() != null) { + for (String uri : request.getPostLogoutRedirectUris()) { + builder.postLogoutRedirectUri(uri); + } + } + } + + /** + * Configure scopes + */ + private void configureScopes(RegisteredClient.Builder builder, ClientRegistrationRequest request) { + if (StringUtils.hasText(request.getScope())) { + String[] scopes = request.getScope().split(" "); + for (String scope : scopes) { + builder.scope(scope.trim()); + } + } else { + // Default ESPI scopes + builder.scope("openid"); + builder.scope("profile"); + builder.scope("FB=4_5_15;IntervalDuration=3600;BlockDuration=monthly;HistoryLength=13"); + } + } + + /** + * Configure client settings + */ + private void configureClientSettings(RegisteredClient.Builder builder, ClientRegistrationRequest request) { + ClientSettings.Builder settingsBuilder = ClientSettings.builder(); + + // ESPI clients typically require consent for customer data access + boolean requireConsent = true; + if (request.getGrantTypes() != null && request.getGrantTypes().contains("client_credentials")) { + requireConsent = false; // Admin clients don't need consent + } + settingsBuilder.requireAuthorizationConsent(requireConsent); + + builder.clientSettings(settingsBuilder.build()); + } + + /** + * Configure token settings + */ + private void configureTokenSettings(RegisteredClient.Builder builder, ClientRegistrationRequest request) { + TokenSettings.Builder settingsBuilder = TokenSettings.builder(); + + // ESPI standard uses opaque access tokens + settingsBuilder.accessTokenFormat(OAuth2TokenFormat.REFERENCE); + + // Set token lifetimes + settingsBuilder.accessTokenTimeToLive(Duration.ofMinutes(360)); // 6 hours for ESPI + settingsBuilder.refreshTokenTimeToLive(Duration.ofMinutes(3600)); // 60 hours + settingsBuilder.reuseRefreshTokens(true); + + builder.tokenSettings(settingsBuilder.build()); + } + + /** + * Build client registration response + */ + private ClientRegistrationResponse buildRegistrationResponse(RegisteredClient client, String clientSecret) { + ClientRegistrationResponse response = new ClientRegistrationResponse(); + response.setClientId(client.getClientId()); + response.setClientSecret(clientSecret); // Only set on initial registration + response.setClientName(client.getClientName()); + response.setClientIdIssuedAt(client.getClientIdIssuedAt() != null ? + client.getClientIdIssuedAt().getEpochSecond() : null); + response.setClientSecretExpiresAt(client.getClientSecretExpiresAt() != null ? + client.getClientSecretExpiresAt().getEpochSecond() : 0); + + // Authentication method + if (!client.getClientAuthenticationMethods().isEmpty()) { + response.setTokenEndpointAuthMethod( + client.getClientAuthenticationMethods().iterator().next().getValue()); + } + + // Grant types + response.setGrantTypes(client.getAuthorizationGrantTypes().stream() + .map(grantType -> grantType.getValue()) + .toList()); + + // Redirect URIs + response.setRedirectUris(new ArrayList<>(client.getRedirectUris())); + if (!client.getPostLogoutRedirectUris().isEmpty()) { + response.setPostLogoutRedirectUris(new ArrayList<>(client.getPostLogoutRedirectUris())); + } + + // Scopes + response.setScope(String.join(" ", client.getScopes())); + + return response; + } + + /** + * Generate client ID + */ + private String generateClientId() { + return "espi_client_" + System.currentTimeMillis() + "_" + + Integer.toHexString(secureRandom.nextInt()); + } + + /** + * Generate client secret + */ + private String generateClientSecret() { + byte[] secretBytes = new byte[32]; + secureRandom.nextBytes(secretBytes); + return Base64.getUrlEncoder().withoutPadding().encodeToString(secretBytes); + } + + /** + * Validate redirect URI with ESPI-specific rules + */ + private boolean isValidRedirectUri(String uri) { + // Basic URI validation + if (!StringUtils.hasText(uri)) { + return false; + } + + // Must be absolute URI + if (!uri.startsWith("http://") && !uri.startsWith("https://")) { + return false; + } + + // ESPI Security: Enforce HTTPS in production + if (requireHttpsRedirectUris && uri.startsWith("http://")) { + // Allow localhost HTTP for development/testing only + if (!uri.contains("localhost") && !uri.contains("127.0.0.1")) { + logger.warn("Rejecting HTTP redirect URI in production: {}", uri); + return false; + } + } + + // Additional ESPI validation rules + try { + java.net.URI parsedUri = java.net.URI.create(uri); + + // Reject javascript: and data: schemes for security + String scheme = parsedUri.getScheme(); + if ("javascript".equalsIgnoreCase(scheme) || "data".equalsIgnoreCase(scheme)) { + logger.warn("Rejecting dangerous URI scheme: {}", scheme); + return false; + } + + // Reject malicious patterns + if (uri.contains("