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:
@@ -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+).
|
||||
|
||||
Reference in New Issue
Block a user