feat(sprint-3): Phase 5 — member portal (session-based auth)

This commit is contained in:
Patrick Plate
2026-06-12 10:11:58 +02:00
parent 64927a3244
commit 87568e5bfc
13 changed files with 785 additions and 3 deletions
@@ -0,0 +1,72 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.security.PortalPrincipal;
import de.cannamanage.service.PortalService;
import de.cannamanage.service.dto.portal.PortalDashboard;
import de.cannamanage.service.dto.portal.PortalDistributionHistory;
import de.cannamanage.service.dto.portal.PortalProfile;
import de.cannamanage.service.dto.portal.PortalQuota;
import org.springframework.data.domain.PageRequest;
import org.springframework.data.domain.Pageable;
import org.springframework.http.ResponseEntity;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
/**
* Member self-service portal — read-only JSON endpoints.
* All data is scoped to the authenticated member via session principal.
*/
@RestController
@RequestMapping("/portal")
public class PortalController {
private final PortalService portalService;
public PortalController(PortalService portalService) {
this.portalService = portalService;
}
/**
* Dashboard: quota summary + recent distributions (last 5).
*/
@GetMapping("/dashboard")
public ResponseEntity<PortalDashboard> dashboard(@AuthenticationPrincipal PortalPrincipal principal) {
PortalDashboard dashboard = portalService.getDashboard(principal.getTenantId(), principal.getMemberId());
return ResponseEntity.ok(dashboard);
}
/**
* Member's own profile.
*/
@GetMapping("/me")
public ResponseEntity<PortalProfile> profile(@AuthenticationPrincipal PortalPrincipal principal) {
PortalProfile profile = portalService.getProfile(principal.getTenantId(), principal.getMemberId());
return ResponseEntity.ok(profile);
}
/**
* Current month quota status (daily + monthly, used/remaining).
*/
@GetMapping("/quota")
public ResponseEntity<PortalQuota> quota(@AuthenticationPrincipal PortalPrincipal principal) {
PortalQuota quota = portalService.getQuota(principal.getTenantId(), principal.getMemberId());
return ResponseEntity.ok(quota);
}
/**
* Own distribution history, paginated.
*/
@GetMapping("/distributions")
public ResponseEntity<PortalDistributionHistory> distributions(
@AuthenticationPrincipal PortalPrincipal principal,
@RequestParam(defaultValue = "0") int page,
@RequestParam(defaultValue = "20") int size) {
Pageable pageable = PageRequest.of(page, Math.min(size, 100));
PortalDistributionHistory history = portalService.getDistributionHistory(
principal.getTenantId(), principal.getMemberId(), pageable);
return ResponseEntity.ok(history);
}
}
@@ -0,0 +1,33 @@
package de.cannamanage.api.security;
import org.springframework.security.core.GrantedAuthority;
import org.springframework.security.core.userdetails.User;
import java.util.Collection;
import java.util.UUID;
/**
* Custom UserDetails principal for member portal sessions.
* Carries tenantId and memberId so portal controllers can enforce data scoping.
*/
public class PortalPrincipal extends User {
private final UUID tenantId;
private final UUID memberId;
public PortalPrincipal(String username, String password,
Collection<? extends GrantedAuthority> authorities,
UUID tenantId, UUID memberId) {
super(username, password, authorities);
this.tenantId = tenantId;
this.memberId = memberId;
}
public UUID getTenantId() {
return tenantId;
}
public UUID getMemberId() {
return memberId;
}
}
@@ -0,0 +1,55 @@
package de.cannamanage.api.security;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.UserRole;
import de.cannamanage.service.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
import org.springframework.security.core.userdetails.UserDetailsService;
import org.springframework.security.core.userdetails.UsernameNotFoundException;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* UserDetailsService for portal session-based auth.
* Only loads MEMBER-role users who are active. Members log in by email.
*/
@Service("portalUserDetailsService")
@RequiredArgsConstructor
public class PortalUserDetailsService implements UserDetailsService {
private final UserRepository userRepository;
@Override
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
User user = userRepository.findByEmail(email)
.orElseThrow(() -> new UsernameNotFoundException("No user found with email: " + email));
// Only MEMBER role users may use the portal
if (user.getRole() != UserRole.ROLE_MEMBER) {
throw new UsernameNotFoundException("User is not a member");
}
// Must be active
if (!user.isActive()) {
throw new UsernameNotFoundException("User account is inactive");
}
// Must have a linked memberId
if (user.getMemberId() == null) {
throw new UsernameNotFoundException("User has no linked member profile");
}
var authorities = List.of(new SimpleGrantedAuthority("ROLE_MEMBER"));
return new PortalPrincipal(
user.getEmail(),
user.getPasswordHash(),
authorities,
user.getTenantId(),
user.getMemberId()
);
}
}
@@ -12,10 +12,13 @@ import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
import org.springframework.security.web.csrf.CookieCsrfTokenRepository;
import jakarta.servlet.http.HttpServletResponse;
/**
* Security configuration — Sprint 3: API + Staff portal with JWT.
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service).
* Security configuration — Sprint 3: API + Staff portal with JWT + Member portal with sessions.
* Roles: ADMIN (full access) + STAFF (permission-based) + MEMBER (self-service portal).
*/
@Configuration
@EnableWebSecurity
@@ -24,6 +27,7 @@ import org.springframework.security.web.authentication.UsernamePasswordAuthentic
public class SecurityConfig {
private final JwtAuthFilter jwtAuthFilter;
private final PortalUserDetailsService portalUserDetailsService;
/**
* API security — stateless JWT authentication.
@@ -53,10 +57,52 @@ public class SecurityConfig {
}
/**
* Public endpoints — Swagger UI, actuator health.
* Member portal — session-based authentication with CSRF protection.
* React SPA consumes JSON responses; custom success/failure handlers return JSON (not redirects).
*/
@Bean
@Order(2)
public SecurityFilterChain portalSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/portal/**")
.csrf(csrf -> csrf
.csrfTokenRepository(CookieCsrfTokenRepository.withHttpOnlyFalse()))
.sessionManagement(session -> session
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(1))
.userDetailsService(portalUserDetailsService)
.formLogin(form -> form
.loginProcessingUrl("/portal/login")
.successHandler((request, response, authentication) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().write("{\"status\":\"ok\"}");
})
.failureHandler((request, response, exception) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
response.getWriter().write("{\"error\":\"Invalid credentials\"}");
})
.permitAll())
.logout(logout -> logout
.logoutUrl("/portal/logout")
.logoutSuccessHandler((request, response, authentication) -> {
response.setContentType("application/json");
response.setStatus(HttpServletResponse.SC_OK);
response.getWriter().write("{\"status\":\"logged_out\"}");
}))
.authorizeHttpRequests(auth -> auth
.requestMatchers("/portal/login", "/portal/css/**", "/portal/js/**").permitAll()
.requestMatchers("/portal/**").hasRole("MEMBER"));
return http.build();
}
/**
* Public endpoints — Swagger UI, actuator health.
*/
@Bean
@Order(3)
public SecurityFilterChain publicSecurityFilterChain(HttpSecurity http) throws Exception {
http
.securityMatcher("/swagger-ui/**", "/v3/api-docs/**", "/actuator/health")