feat(sprint-3): Phase 3 — staff management + invite flow

- Step 3.1: Spring Boot Starter Mail dependency (api + service)
- Step 3.2: InviteToken JPA entity with 72h expiry
- Step 3.3: InviteTokenRepository with valid-token finder
- Step 3.4: EmailService (plain text invite email via JavaMailSender)
- Step 3.5: StaffService (CRUD + invite + email pattern validation + token revocation)
- Step 3.6: Staff DTOs (CreateStaffRequest, UpdateStaffRequest, StaffResponse)
- Step 3.7: SetPasswordRequest with password complexity (@Pattern: 1 digit + 1 special)
- Step 3.8: StaffController (6 endpoints, ADMIN-only via @PreAuthorize)
- Step 3.9: POST /api/v1/auth/set-password (public, generic error messages)
- Step 3.10: StaffTemplates (ausgabe, lager, vorstand predefined permission sets)
- Step 3.11: AuthService rejects inactive users with 'Account not activated'
- Step 3.12: Token revocation on permission change via revokeAllForUser()
- Step 3.13: invite-email.txt template (German, 72h expiry note)
- Step 3.14: Spring Mail config (Mailpit dev defaults, env var overrides)
- Step 3.15: Unit tests (StaffServiceTest, StaffControllerTest, EmailServiceTest)
- V5 Flyway migration for invite_tokens table

Security review findings incorporated:
- Password complexity: min 8 chars, 1 digit + 1 special char
- Generic 'invalid or expired token' error (no state leakage)
- SecureRandom 32-byte Base64 token generation
- Token values never logged
This commit is contained in:
Patrick Plate
2026-06-11 18:03:12 +02:00
parent 36deb72cf0
commit 6c66783b58
22 changed files with 1205 additions and 3 deletions
+5
View File
@@ -113,6 +113,11 @@
<artifactId>spring-boot-testcontainers</artifactId>
<scope>test</scope>
</dependency>
<!-- Spring Boot Mail (invite flow) -->
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
</dependencies>
<build>
@@ -3,6 +3,7 @@ package de.cannamanage.api.controller;
import de.cannamanage.api.dto.auth.LoginRequest;
import de.cannamanage.api.dto.auth.LoginResponse;
import de.cannamanage.api.dto.auth.RefreshRequest;
import de.cannamanage.api.dto.auth.SetPasswordRequest;
import de.cannamanage.api.service.AuthService;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
@@ -14,6 +15,8 @@ import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/api/v1/auth")
@RequiredArgsConstructor
@@ -35,4 +38,12 @@ public class AuthController {
LoginResponse response = authService.refresh(request);
return ResponseEntity.ok(response);
}
@PostMapping("/set-password")
@Operation(summary = "Set password via invite token",
description = "Public endpoint — validates invite token, sets password, activates account")
public ResponseEntity<Map<String, String>> setPassword(@Valid @RequestBody SetPasswordRequest request) {
authService.setPassword(request);
return ResponseEntity.ok(Map.of("message", "Password set successfully. You can now log in."));
}
}
@@ -0,0 +1,112 @@
package de.cannamanage.api.controller;
import de.cannamanage.api.dto.staff.CreateStaffRequest;
import de.cannamanage.api.dto.staff.StaffResponse;
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.service.StaffService;
import de.cannamanage.service.StaffTemplates;
import de.cannamanage.service.repository.UserRepository;
import io.swagger.v3.oas.annotations.Operation;
import io.swagger.v3.oas.annotations.tags.Tag;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.HttpStatus;
import org.springframework.http.ResponseEntity;
import org.springframework.security.access.prepost.PreAuthorize;
import org.springframework.web.bind.annotation.*;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
@RestController
@RequestMapping("/api/v1/staff")
@RequiredArgsConstructor
@Tag(name = "Staff Management", description = "Staff CRUD + invite flow (ADMIN only)")
public class StaffController {
private final StaffService staffService;
private final UserRepository userRepository;
@GetMapping
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "List all active staff members")
public ResponseEntity<List<StaffResponse>> listStaff() {
UUID tenantId = TenantContext.getCurrentTenant();
List<StaffAccount> staffList = staffService.listStaff(tenantId);
List<StaffResponse> response = staffList.stream()
.map(staff -> {
User user = userRepository.findById(staff.getUserId()).orElse(null);
String email = user != null ? user.getEmail() : "unknown";
return StaffResponse.from(staff, email);
})
.toList();
return ResponseEntity.ok(response);
}
@PostMapping
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Create staff member + send invite email")
public ResponseEntity<StaffResponse> createStaff(@Valid @RequestBody CreateStaffRequest request) {
UUID tenantId = TenantContext.getCurrentTenant();
StaffAccount staff = staffService.createStaff(
tenantId,
request.email(),
request.displayName(),
request.permissions(),
request.templateName()
);
return ResponseEntity.status(HttpStatus.CREATED)
.body(StaffResponse.from(staff, request.email()));
}
@GetMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Get staff member by ID")
public ResponseEntity<StaffResponse> getStaff(@PathVariable UUID id) {
UUID tenantId = TenantContext.getCurrentTenant();
StaffAccount staff = staffService.getStaff(tenantId, id);
User user = userRepository.findById(staff.getUserId()).orElse(null);
String email = user != null ? user.getEmail() : "unknown";
return ResponseEntity.ok(StaffResponse.from(staff, email));
}
@PutMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Update staff permissions/profile (revokes tokens on permission change)")
public ResponseEntity<StaffResponse> updateStaff(@PathVariable UUID id,
@RequestBody UpdateStaffRequest request) {
UUID tenantId = TenantContext.getCurrentTenant();
StaffAccount staff = staffService.updateStaff(
tenantId, id,
request.displayName(),
request.permissions(),
request.templateName(),
request.active()
);
User user = userRepository.findById(staff.getUserId()).orElse(null);
String email = user != null ? user.getEmail() : "unknown";
return ResponseEntity.ok(StaffResponse.from(staff, email));
}
@DeleteMapping("/{id}")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "Deactivate staff member (revokes all tokens)")
public ResponseEntity<Void> deactivateStaff(@PathVariable UUID id) {
UUID tenantId = TenantContext.getCurrentTenant();
staffService.deactivateStaff(tenantId, id);
return ResponseEntity.noContent().build();
}
@GetMapping("/templates")
@PreAuthorize("hasRole('ADMIN')")
@Operation(summary = "List available permission templates")
public ResponseEntity<Map<String, Set<StaffPermission>>> listTemplates() {
return ResponseEntity.ok(StaffTemplates.getAllTemplates());
}
}
@@ -0,0 +1,18 @@
package de.cannamanage.api.dto.auth;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Pattern;
import jakarta.validation.constraints.Size;
/**
* Request DTO for setting password via invite token.
* Password complexity: min 8 chars, at least 1 digit + 1 special character.
*/
public record SetPasswordRequest(
@NotBlank String token,
@NotBlank
@Size(min = 8, max = 100, message = "Password must be between 8 and 100 characters")
@Pattern(regexp = "^(?=.*\\d)(?=.*[!@#$%^&*()_+\\-=\\[\\]{};':\"\\\\|,.<>/?]).+$",
message = "Password must contain at least 1 digit and 1 special character")
String password
) {}
@@ -0,0 +1,17 @@
package de.cannamanage.api.dto.staff;
import de.cannamanage.domain.enums.StaffPermission;
import jakarta.validation.constraints.Email;
import jakarta.validation.constraints.NotBlank;
import java.util.Set;
/**
* Request DTO for creating a new staff member (admin invite flow).
*/
public record CreateStaffRequest(
@NotBlank @Email String email,
@NotBlank String displayName,
Set<StaffPermission> permissions,
String templateName
) {}
@@ -0,0 +1,49 @@
package de.cannamanage.api.dto.staff;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.StaffPermission;
import java.time.Instant;
import java.util.Set;
import java.util.UUID;
/**
* Response DTO for staff member information.
*/
public record StaffResponse(
UUID id,
UUID userId,
String email,
String displayName,
Set<StaffPermission> permissions,
String templateName,
boolean active,
Instant createdAt
) {
public static StaffResponse from(StaffAccount staff, User user) {
return new StaffResponse(
staff.getId(),
staff.getUserId(),
user.getEmail(),
staff.getDisplayName(),
staff.getGrantedPermissions(),
null, // templateName not stored; permissions are expanded
staff.isActive(),
staff.getCreatedAt()
);
}
public static StaffResponse from(StaffAccount staff, String email) {
return new StaffResponse(
staff.getId(),
staff.getUserId(),
email,
staff.getDisplayName(),
staff.getGrantedPermissions(),
null,
staff.isActive(),
staff.getCreatedAt()
);
}
}
@@ -0,0 +1,15 @@
package de.cannamanage.api.dto.staff;
import de.cannamanage.domain.enums.StaffPermission;
import java.util.Set;
/**
* Request DTO for updating an existing staff member.
*/
public record UpdateStaffRequest(
String displayName,
Set<StaffPermission> permissions,
String templateName,
Boolean active
) {}
@@ -40,6 +40,7 @@ public class SecurityConfig {
.authorizeHttpRequests(auth -> auth
.requestMatchers("/api/v1/auth/**").permitAll()
.requestMatchers("/api/v1/admin/**").hasRole("ADMIN")
.requestMatchers("/api/v1/staff/**").hasRole("ADMIN")
.requestMatchers("/api/v1/members/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers("/api/v1/distributions/**").hasAnyRole("ADMIN", "STAFF", "MEMBER")
.requestMatchers("/api/v1/stock/**").hasAnyRole("ADMIN", "STAFF")
@@ -3,8 +3,13 @@ package de.cannamanage.api.service;
import de.cannamanage.api.dto.auth.LoginRequest;
import de.cannamanage.api.dto.auth.LoginResponse;
import de.cannamanage.api.dto.auth.RefreshRequest;
import de.cannamanage.api.dto.auth.SetPasswordRequest;
import de.cannamanage.api.security.JwtService;
import de.cannamanage.domain.entity.InviteToken;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.entity.User;
import de.cannamanage.service.repository.InviteTokenRepository;
import de.cannamanage.service.repository.StaffAccountRepository;
import de.cannamanage.service.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
@@ -20,7 +25,7 @@ import java.util.HexFormat;
import java.util.UUID;
/**
* Authentication service — handles login and token refresh.
* Authentication service — handles login, token refresh, and invite-based password setup.
* Stateless JWT approach: no UserDetailsService needed.
* Refresh tokens are hashed and stored on the User entity for revocation support.
*/
@@ -32,6 +37,8 @@ public class AuthService {
private final UserRepository userRepository;
private final JwtService jwtService;
private final PasswordEncoder passwordEncoder;
private final InviteTokenRepository inviteTokenRepository;
private final StaffAccountRepository staffAccountRepository;
@Transactional
public LoginResponse login(LoginRequest request) {
@@ -39,7 +46,7 @@ public class AuthService {
.orElseThrow(() -> new AuthenticationException("Invalid credentials"));
if (!user.isActive()) {
throw new AuthenticationException("Account is disabled");
throw new AuthenticationException("Account not activated");
}
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
@@ -75,7 +82,7 @@ public class AuthService {
.orElseThrow(() -> new AuthenticationException("User not found"));
if (!user.isActive()) {
throw new AuthenticationException("Account is disabled");
throw new AuthenticationException("Account not activated");
}
// Verify the refresh token matches stored hash (revocation check)
@@ -96,6 +103,39 @@ public class AuthService {
return new LoginResponse(newAccessToken, newRefreshToken, 3600L, roleName);
}
/**
* Sets the password for a user via invite token.
* Validates the token, sets the password hash, marks user active, marks token as used.
* Security: generic error message for invalid/expired tokens (don't reveal state).
*/
@Transactional
public void setPassword(SetPasswordRequest request) {
// Find valid (unused + not expired) token — security: generic error message
InviteToken inviteToken = inviteTokenRepository
.findByTokenAndUsedAtIsNullAndExpiresAtAfter(request.token(), Instant.now())
.orElseThrow(() -> new AuthenticationException("Invalid or expired token"));
User user = inviteToken.getUser();
// Set password and activate user
user.setPasswordHash(passwordEncoder.encode(request.password()));
user.setActive(true);
userRepository.save(user);
// Mark token as used
inviteToken.setUsedAt(Instant.now());
inviteTokenRepository.save(inviteToken);
// Update staff account activation timestamp
staffAccountRepository.findByUserId(user.getId())
.ifPresent(staff -> {
staff.setActivatedAt(Instant.now());
staffAccountRepository.save(staff);
});
log.info("Password set for user {} via invite token", user.getEmail());
}
/**
* SHA-256 hash for refresh token storage.
* JWTs exceed BCrypt's 72-byte limit (enforced in Spring Security 7+).
@@ -18,3 +18,15 @@ springdoc.swagger-ui.operations-sorter=method
# Enable Spring AOP for TenantFilterAspect
spring.aop.auto=true
spring.aop.proxy-target-class=true
# Spring Mail (dev defaults: Mailpit on localhost:1025)
spring.mail.host=${SMTP_HOST:localhost}
spring.mail.port=${SMTP_PORT:1025}
spring.mail.username=${SMTP_USERNAME:}
spring.mail.password=${SMTP_PASSWORD:}
spring.mail.properties.mail.smtp.auth=${SMTP_AUTH:false}
spring.mail.properties.mail.smtp.starttls.enable=${SMTP_STARTTLS:false}
spring.mail.from=${MAIL_FROM:noreply@cannamanage.de}
# App base URL (for invite links)
app.base-url=${APP_BASE_URL:http://localhost:8080}
@@ -0,0 +1,13 @@
-- Sprint 3 Phase 3: Invite tokens for staff onboarding
CREATE TABLE invite_tokens (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
token VARCHAR(64) NOT NULL UNIQUE,
expires_at TIMESTAMPTZ NOT NULL,
used_at TIMESTAMPTZ,
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
CREATE INDEX idx_invite_tokens_token ON invite_tokens(token);
CREATE INDEX idx_invite_tokens_user_id ON invite_tokens(user_id);
@@ -0,0 +1,14 @@
Hallo {displayName},
Du wurdest als Mitarbeiter/in beim Anbauverein "{clubName}" eingeladen.
Bitte klicke auf den folgenden Link, um dein Passwort festzulegen und deinen Account zu aktivieren:
{setPasswordUrl}
Dieser Link ist 72 Stunden gültig.
Falls du diese Einladung nicht erwartet hast, kannst du diese E-Mail ignorieren.
Viele Grüße,
Dein CannaManage-Team
@@ -0,0 +1,166 @@
package de.cannamanage.api.controller;
import com.fasterxml.jackson.databind.ObjectMapper;
import de.cannamanage.api.dto.staff.CreateStaffRequest;
import de.cannamanage.api.dto.staff.UpdateStaffRequest;
import de.cannamanage.api.security.JwtAuthFilter;
import de.cannamanage.api.security.JwtService;
import de.cannamanage.domain.entity.StaffAccount;
import de.cannamanage.domain.entity.TenantContext;
import de.cannamanage.domain.entity.User;
import de.cannamanage.domain.enums.StaffPermission;
import de.cannamanage.domain.enums.UserRole;
import de.cannamanage.service.StaffService;
import de.cannamanage.service.TokenRevocationService;
import de.cannamanage.service.repository.UserRepository;
import org.junit.jupiter.api.BeforeEach;
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.http.MediaType;
import org.springframework.security.test.context.support.WithMockUser;
import org.springframework.test.web.servlet.MockMvc;
import java.time.Instant;
import java.util.*;
import static org.mockito.ArgumentMatchers.any;
import static org.mockito.ArgumentMatchers.eq;
import static org.mockito.Mockito.verify;
import static org.mockito.Mockito.when;
import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf;
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.*;
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*;
@WebMvcTest(StaffController.class)
class StaffControllerTest {
@Autowired private MockMvc mockMvc;
@Autowired private ObjectMapper objectMapper;
@MockBean private StaffService staffService;
@MockBean private UserRepository userRepository;
@MockBean private JwtService jwtService;
@MockBean private JwtAuthFilter jwtAuthFilter;
@MockBean private TokenRevocationService tokenRevocationService;
private UUID tenantId;
private UUID staffId;
private UUID userId;
@BeforeEach
void setUp() {
tenantId = UUID.randomUUID();
staffId = UUID.randomUUID();
userId = UUID.randomUUID();
TenantContext.setCurrentTenant(tenantId);
}
@Test
@WithMockUser(roles = "ADMIN")
void listStaff_returnsStaffList() throws Exception {
StaffAccount staff = createStaffAccount();
User user = createUser();
when(staffService.listStaff(tenantId)).thenReturn(List.of(staff));
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
mockMvc.perform(get("/api/v1/staff"))
.andExpect(status().isOk())
.andExpect(jsonPath("$[0].displayName").value("Test Staff"))
.andExpect(jsonPath("$[0].email").value("staff@test.de"));
}
@Test
@WithMockUser(roles = "ADMIN")
void createStaff_validRequest_returns201() throws Exception {
CreateStaffRequest request = new CreateStaffRequest(
"new@test.de", "New Staff",
EnumSet.of(StaffPermission.VIEW_STOCK), null);
StaffAccount created = createStaffAccount();
when(staffService.createStaff(eq(tenantId), eq("new@test.de"), eq("New Staff"), any(), any()))
.thenReturn(created);
mockMvc.perform(post("/api/v1/staff")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isCreated())
.andExpect(jsonPath("$.displayName").value("Test Staff"));
}
@Test
@WithMockUser(roles = "ADMIN")
void createStaff_invalidEmail_returns400() throws Exception {
CreateStaffRequest request = new CreateStaffRequest(
"not-an-email", "Bad Staff",
EnumSet.of(StaffPermission.VIEW_STOCK), null);
mockMvc.perform(post("/api/v1/staff")
.with(csrf())
.contentType(MediaType.APPLICATION_JSON)
.content(objectMapper.writeValueAsString(request)))
.andExpect(status().isBadRequest());
}
@Test
@WithMockUser(roles = "ADMIN")
void getStaff_returns200() throws Exception {
StaffAccount staff = createStaffAccount();
User user = createUser();
when(staffService.getStaff(tenantId, staffId)).thenReturn(staff);
when(userRepository.findById(userId)).thenReturn(Optional.of(user));
mockMvc.perform(get("/api/v1/staff/{id}", staffId))
.andExpect(status().isOk())
.andExpect(jsonPath("$.id").value(staffId.toString()));
}
@Test
@WithMockUser(roles = "ADMIN")
void deactivateStaff_returns204() throws Exception {
mockMvc.perform(delete("/api/v1/staff/{id}", staffId).with(csrf()))
.andExpect(status().isNoContent());
verify(staffService).deactivateStaff(tenantId, staffId);
}
@Test
@WithMockUser(roles = "ADMIN")
void listTemplates_returnsTemplateMap() throws Exception {
mockMvc.perform(get("/api/v1/staff/templates"))
.andExpect(status().isOk())
.andExpect(jsonPath("$.ausgabe").isArray())
.andExpect(jsonPath("$.lager").isArray())
.andExpect(jsonPath("$.vorstand").isArray());
}
@Test
@WithMockUser(roles = "MEMBER")
void listStaff_asMember_returns403() throws Exception {
mockMvc.perform(get("/api/v1/staff"))
.andExpect(status().isForbidden());
}
private StaffAccount createStaffAccount() {
StaffAccount staff = new StaffAccount();
staff.setId(staffId);
staff.setTenantId(tenantId);
staff.setUserId(userId);
staff.setDisplayName("Test Staff");
staff.setGrantedPermissions(EnumSet.of(StaffPermission.VIEW_STOCK));
staff.setActive(true);
staff.setCreatedAt(Instant.now());
return staff;
}
private User createUser() {
User user = new User();
user.setId(userId);
user.setEmail("staff@test.de");
user.setRole(UserRole.ROLE_STAFF);
user.setActive(true);
return user;
}
}