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")
@@ -30,3 +30,9 @@ spring.mail.from=${MAIL_FROM:noreply@cannamanage.de}
# App base URL (for invite links)
app.base-url=${APP_BASE_URL:http://localhost:8080}
# Session configuration (member portal)
server.servlet.session.timeout=30m
server.servlet.session.cookie.same-site=strict
server.servlet.session.cookie.http-only=true
server.servlet.session.cookie.secure=${SESSION_COOKIE_SECURE:false}
@@ -0,0 +1,112 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.security.JwtAuthFilter;
import de.cannamanage.api.security.JwtService;
import de.cannamanage.api.security.PortalPrincipal;
import de.cannamanage.api.security.PortalUserDetailsService;
import de.cannamanage.service.PortalService;
import de.cannamanage.service.TokenRevocationService;
import de.cannamanage.service.dto.portal.PortalDashboard;
import de.cannamanage.service.dto.portal.PortalProfile;
import de.cannamanage.service.dto.portal.PortalQuota;
import de.cannamanage.service.repository.UserRepository;
import de.cannamanage.domain.enums.MemberStatus;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest;
import org.springframework.boot.test.mock.bean.MockBean;
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.test.web.servlet.MockMvc;
import java.math.BigDecimal;
import java.time.LocalDate;
import java.util.List;
import java.util.UUID;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.user;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(PortalController.class)
class PortalControllerTest {
@Autowired private MockMvc mockMvc;
@MockBean private PortalService portalService;
@MockBean private UserRepository userRepository;
@MockBean private JwtService jwtService;
@MockBean private JwtAuthFilter jwtAuthFilter;
@MockBean private TokenRevocationService tokenRevocationService;
@MockBean private PortalUserDetailsService portalUserDetailsService;
private final UUID tenantId = UUID.randomUUID();
private final UUID memberId = UUID.randomUUID();
private PortalPrincipal createPrincipal() {
return new PortalPrincipal(
"member@test.de", "password",
List.of(new SimpleGrantedAuthority("ROLE_MEMBER")),
tenantId, memberId
);
}
@Test
void dashboard_returnsCorrectQuotaData() throws Exception {
PortalPrincipal principal = createPrincipal();
PortalDashboard dashboard = new PortalDashboard(
"Max Mustermann", "CM-001",
new BigDecimal("12.5"), new BigDecimal("37.5"),
new BigDecimal("5.0"), new BigDecimal("20.0"),
List.of()
);
when(portalService.getDashboard(tenantId, memberId)).thenReturn(dashboard);
mockMvc.perform(get("/portal/dashboard").with(user(principal)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.memberName").value("Max Mustermann"))
.andExpect(jsonPath("$.membershipNumber").value("CM-001"))
.andExpect(jsonPath("$.monthlyQuotaUsed").value(12.5))
.andExpect(jsonPath("$.dailyQuotaRemaining").value(20.0));
}
@Test
void portal_returns403_forUnauthenticated() throws Exception {
mockMvc.perform(get("/portal/dashboard"))
.andExpect(status().isUnauthorized());
}
@Test
void profile_returnsOwnData() throws Exception {
PortalPrincipal principal = createPrincipal();
PortalProfile profile = new PortalProfile(
"Max", "Mustermann", "CM-001",
LocalDate.of(2025, 1, 15), MemberStatus.ACTIVE, "max@example.com"
);
when(portalService.getProfile(tenantId, memberId)).thenReturn(profile);
mockMvc.perform(get("/portal/me").with(user(principal)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.firstName").value("Max"))
.andExpect(jsonPath("$.membershipNumber").value("CM-001"));
}
@Test
void quota_returnsCurrentMonthStatus() throws Exception {
PortalPrincipal principal = createPrincipal();
PortalQuota quota = new PortalQuota(
2026, 6,
new BigDecimal("5.0"), new BigDecimal("25.0"),
new BigDecimal("20.0"), new BigDecimal("50.0"),
false, new BigDecimal("30.0")
);
when(portalService.getQuota(tenantId, memberId)).thenReturn(quota);
mockMvc.perform(get("/portal/quota").with(user(principal)))
.andExpect(status().isOk())
.andExpect(jsonPath("$.year").value(2026))
.andExpect(jsonPath("$.month").value(6))
.andExpect(jsonPath("$.dailyUsed").value(5.0))
.andExpect(jsonPath("$.monthlyLimit").value(50.0));
}
}