feat(sprint-3): Phase 5 — member portal (session-based auth)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
+55
@@ -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}
|
||||
|
||||
+112
@@ -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));
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user