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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -36,4 +36,6 @@ public interface RetailCustomerService {

RetailCustomerEntity findByUsername(String username);

void deleteById(Long retailCustomerId);

}
Original file line number Diff line number Diff line change
Expand Up @@ -69,4 +69,9 @@ public RetailCustomerEntity findByUsername(String username) {
}
}

@Override
public void deleteById(Long retailCustomerId) {
retailCustomerRepository.deleteById(retailCustomerId);
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,7 @@
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.http.SessionCreationPolicy;
import org.springframework.security.core.Authentication;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.config.Customizer;
Expand Down Expand Up @@ -88,10 +89,17 @@ public SecurityFilterChain customerLoginSecurityFilterChain(
provider.setPasswordEncoder(customerPasswordEncoder);

PathPatternRequestMatcher.Builder pp = PathPatternRequestMatcher.withDefaults();
// This session/form-login chain owns the human-facing UI surface. The public landing
// ("/", "/home") is included so the shared navbar "Home" link is session-aware and does not
// fall through to the stateless resource-server chain (which would 401 it). The ESPI API
// (/espi/**) stays on the resource-server chain.
RequestMatcher matcher = new OrRequestMatcher(
pp.matcher("/"),
pp.matcher("/home"),
pp.matcher("/login"),
pp.matcher("/logout"),
pp.matcher("/custodian/**"),
pp.matcher("/customer/**"),
pp.matcher("/oauth/authorize-screen/**"));

return http
Expand All @@ -103,7 +111,7 @@ public SecurityFilterChain customerLoginSecurityFilterChain(
// session-stored token. Right shape for vanilla form login.
.csrf(Customizer.withDefaults())
.authorizeHttpRequests(authz -> authz
.requestMatchers(pp.matcher("/login")).permitAll()
.requestMatchers(pp.matcher("/"), pp.matcher("/home"), pp.matcher("/login")).permitAll()
.anyRequest().authenticated())
.formLogin(form -> form
.loginPage("/login")
Expand Down Expand Up @@ -136,12 +144,26 @@ public void onAuthenticationSuccess(HttpServletRequest request,
getRedirectStrategy().sendRedirect(request, response, returnTo);
}
else {
getRedirectStrategy().sendRedirect(request, response, DEFAULT_SUCCESS_URL);
getRedirectStrategy().sendRedirect(request, response, landingFor(authentication));
}
}
};
}

/**
* Role-aware default landing. Only custodians/admins may see {@code /custodian/home}
* (it is {@code @PreAuthorize("hasRole('ROLE_CUSTODIAN')")}); a regular customer logging in
* would otherwise be redirected there and denied, so they land on the public home page.
*/
private static String landingFor(Authentication authentication) {
boolean custodian = authentication.getAuthorities().stream()
.map(GrantedAuthority::getAuthority)
.anyMatch(a -> a.equals("ROLE_CUSTODIAN") || a.equals("ROLE_ADMIN"));
// Custodians land on the admin dashboard; retail customers on their self-service
// authorizations page (#173).
return custodian ? DEFAULT_SUCCESS_URL : "/customer/authorizations";
}

/**
* Accept only same-origin paths ({@code /foo}) or absolute URLs whose URI parses cleanly.
* Defense in depth against open-redirect — the AS-issued signed handoff in PR C3 will
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,9 +21,11 @@

import org.greenbuttonalliance.espi.common.scope.FunctionBlock;
import org.greenbuttonalliance.espi.common.scope.FunctionBlockCategory;
import org.springframework.boot.security.autoconfigure.web.servlet.PathRequest;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.security.config.annotation.web.configuration.WebSecurityCustomizer;
import org.springframework.core.annotation.Order;
import org.springframework.http.HttpMethod;
import org.springframework.security.authorization.AuthorityAuthorizationManager;
Expand Down Expand Up @@ -77,6 +79,19 @@ public class SecurityConfiguration {
@Value("${espi.authorization-server.client-secret:datacustodian-secret}")
private String clientSecret;

/**
* Exclude the portal's static web assets (CSS/JS/images/webjars/favicon) from the security
* filter chains entirely. These are public, non-sensitive files; routing them through the
* stateless OAuth2 resource-server chain otherwise 401s them and the admin/customer portal
* renders unstyled (#173). {@link PathRequest#toStaticResources()} covers Spring Boot's
* standard static locations.
*/
@Bean
public WebSecurityCustomizer staticResourceSecurityCustomizer() {
return web -> web.ignoring().requestMatchers(
PathRequest.toStaticResources().atCommonLocations());
}

/**
* Main security filter chain for ESPI Resource Server endpoints.
*/
Expand Down Expand Up @@ -140,6 +155,7 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http,
.authorizeHttpRequests(authz -> authz
// Public endpoints
.requestMatchers(
"/error",
"/actuator/health",
"/actuator/info",
"/api-docs/**",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -167,7 +167,19 @@ public void addResourceHandlers(ResourceHandlerRegistry registry) {
registry.addResourceHandler("/static/**")
.addResourceLocations("classpath:/static/")
.setCachePeriod(3600);


// @EnableWebMvc disables Spring Boot's default static-resource mappings, so the portal's
// CSS/JS/images must be registered explicitly; otherwise the templates' /css and /js links
// 404 and the portal renders unstyled (#173). These paths are also security-ignored via the
// WebSecurityCustomizer in SecurityConfiguration.
registry.addResourceHandler("/css/**")
.addResourceLocations("classpath:/static/css/")
.setCachePeriod(3600);

registry.addResourceHandler("/js/**")
.addResourceLocations("classpath:/static/js/")
.setCachePeriod(3600);

registry.addResourceHandler("/images/**")
.addResourceLocations("classpath:/static/images/")
.setCachePeriod(86400);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,17 +23,18 @@
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;

// @Controller - COMMENTED OUT: UI not needed in resource server
// @Component
// Re-enabled (#173): the portal navbar links "Home" to "/"; without this the root 404s → /error →
// resource-server chain → 401. Renders templates/home.html (public landing).
@Controller
public class HomeController {

@GetMapping(Routes.ROOT)
public String index() {
return "/home";
return "home";
}

@GetMapping(Routes.HOME)
public String home() {
return "/home";
return "home";
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,20 +19,56 @@

package org.greenbuttonalliance.espi.datacustodian.web.custodian;

import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity;
import org.greenbuttonalliance.espi.common.repositories.usage.UsagePointRepository;
import org.greenbuttonalliance.espi.common.service.AuthorizationService;
import org.greenbuttonalliance.espi.common.service.RetailCustomerService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;

// The custodian login flow (C2a) redirects to /custodian/home on success
// (CustomerLoginSecurityConfiguration DEFAULT_SUCCESS_URL), so this landing controller must be
// enabled — otherwise the post-login redirect 404s and is re-dispatched to /error, which the
// resource-server chain returns as 401. Template: templates/custodian/home.html.
import java.util.List;

/**
* Custodian portal landing page (admin dashboard).
*
* <p>The custodian login flow (C2a) redirects to {@code /custodian/home} on success
* (CustomerLoginSecurityConfiguration DEFAULT_SUCCESS_URL), so this landing controller must be
* enabled — otherwise the post-login redirect 404s and is re-dispatched to /error, which the
* resource-server chain returns as 401 (#171). This controller also answers the bare
* {@code /custodian} path so the navbar brand/Dashboard link resolves.</p>
*
* <p>Read-only: it renders the dashboard and populates the overview stat tiles with simple entity
* counts. It performs no writes (CRUD remains deferred per #166). Template:
* templates/custodian/home.html.</p>
*/
@Controller
@RequestMapping("/custodian/home")
@PreAuthorize("hasRole('ROLE_CUSTODIAN')")
public class CustodianHomeController {

@GetMapping
public String index() {
return "/custodian/home";
private final RetailCustomerService retailCustomerService;
private final AuthorizationService authorizationService;
private final UsagePointRepository usagePointRepository;

public CustodianHomeController(RetailCustomerService retailCustomerService,
AuthorizationService authorizationService,
UsagePointRepository usagePointRepository) {
this.retailCustomerService = retailCustomerService;
this.authorizationService = authorizationService;
this.usagePointRepository = usagePointRepository;
}

@GetMapping({"/custodian", "/custodian/home"})
public String index(Model model) {
List<AuthorizationEntity> authorizations = authorizationService.findAll();
long activeTokens = authorizations.stream().filter(AuthorizationEntity::isActive).count();

model.addAttribute("totalCustomers", retailCustomerService.findAll().size());
model.addAttribute("activeTokens", activeTokens);
model.addAttribute("totalUsagePoints", usagePointRepository.count());
model.addAttribute("todayRequests", 0);

return "custodian/home";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
/*
*
* 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.datacustodian.web.custodian;

import org.greenbuttonalliance.espi.common.domain.usage.AuthorizationEntity;
import org.greenbuttonalliance.espi.common.domain.usage.RetailCustomerEntity;
import org.greenbuttonalliance.espi.common.service.AuthorizationService;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.stereotype.Controller;
import org.springframework.transaction.annotation.Transactional;
import org.springframework.ui.Model;
import org.springframework.web.bind.annotation.GetMapping;

import java.util.List;

/**
* Custodian OAuth Token Management page (#173). Read-only view of the Authorization grants the Data
* Custodian holds. The legacy portal shipped this page as an empty placeholder; this renders a real
* table over {@link AuthorizationService#findAll()}.
*
* <p>Open-Session-In-View is disabled ({@code spring.jpa.open-in-view=false}), so the lazy
* {@code retailCustomer} relation cannot be touched from the template. The handler is
* {@link Transactional} and projects each entity into a fully-materialized {@link TokenView} record
* before returning, so the view renders only flat data.</p>
*/
@Controller
@PreAuthorize("hasRole('ROLE_CUSTODIAN')")
public class OAuthTokenController {

private final AuthorizationService authorizationService;

public OAuthTokenController(AuthorizationService authorizationService) {
this.authorizationService = authorizationService;
}

@GetMapping("/custodian/oauth/tokens")
@Transactional(readOnly = true)
public String index(Model model) {
List<TokenView> tokens = authorizationService.findAll().stream()
.map(OAuthTokenController::toView)
.toList();
model.addAttribute("tokens", tokens);
return "custodian/oauth/tokens";
}

private static TokenView toView(AuthorizationEntity a) {
RetailCustomerEntity customer = a.getRetailCustomer();
String customerName = customer == null ? "—" : customer.getUsername();

String status;
if (a.isRevoked()) {
status = "REVOKED";
} else if (a.isExpired()) {
status = "EXPIRED";
} else if (a.isActive()) {
status = "ACTIVE";
} else {
status = "PENDING";
}

return new TokenView(
customerName,
a.getThirdParty(),
a.getScope(),
status,
a.getGrantType() == null ? "—" : a.getGrantType().toString(),
mask(a.getAccessToken()));
}

/** Show only the last 4 characters of a token so the page never leaks a usable credential. */
private static String mask(String token) {
if (token == null || token.isBlank()) {
return "—";
}
String trimmed = token.trim();
return trimmed.length() <= 4 ? "••••" : "••••" + trimmed.substring(trimmed.length() - 4);
}

/** Flat, fully-materialized projection safe to render with OSIV disabled. */
public record TokenView(String customer, String thirdParty, String scope, String status,
String grantType, String maskedToken) {
}
}
Loading
Loading