Files
cannamanage/cannamanage-api/src/main/java/de/cannamanage/api/service/AuthService.java
T
Patrick Plate 6c66783b58 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
2026-06-11 18:03:12 +02:00

163 lines
6.4 KiB
Java

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;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.nio.charset.StandardCharsets;
import java.security.MessageDigest;
import java.security.NoSuchAlgorithmException;
import java.time.Instant;
import java.util.HexFormat;
import java.util.UUID;
/**
* 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.
*/
@Service
@Slf4j
@RequiredArgsConstructor
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) {
User user = userRepository.findByEmail(request.email())
.orElseThrow(() -> new AuthenticationException("Invalid credentials"));
if (!user.isActive()) {
throw new AuthenticationException("Account not activated");
}
if (!passwordEncoder.matches(request.password(), user.getPasswordHash())) {
throw new AuthenticationException("Invalid credentials");
}
// Generate tokens
String roleName = user.getRole().name().replace("ROLE_", "");
String accessToken = jwtService.generateAccessToken(
user.getId(), user.getTenantId(), roleName, user.getEmail());
String refreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId());
// Store SHA-256 hashed refresh token for revocation (BCrypt can't handle >72 bytes)
user.setRefreshTokenHash(sha256(refreshToken));
user.setLastLogin(Instant.now());
userRepository.save(user);
log.info("User {} logged in for tenant {}", user.getEmail(), user.getTenantId());
return new LoginResponse(accessToken, refreshToken, 3600L, roleName);
}
@Transactional
public LoginResponse refresh(RefreshRequest request) {
String token = request.refreshToken();
if (!jwtService.isTokenValid(token)) {
throw new AuthenticationException("Invalid or expired refresh token");
}
UUID userId = jwtService.extractUserId(token);
User user = userRepository.findById(userId)
.orElseThrow(() -> new AuthenticationException("User not found"));
if (!user.isActive()) {
throw new AuthenticationException("Account not activated");
}
// Verify the refresh token matches stored hash (revocation check)
if (user.getRefreshTokenHash() == null ||
!sha256(token).equals(user.getRefreshTokenHash())) {
throw new AuthenticationException("Refresh token has been revoked");
}
// Rotate refresh token
String roleName = user.getRole().name().replace("ROLE_", "");
String newAccessToken = jwtService.generateAccessToken(
user.getId(), user.getTenantId(), roleName, user.getEmail());
String newRefreshToken = jwtService.generateRefreshToken(user.getId(), user.getTenantId());
user.setRefreshTokenHash(sha256(newRefreshToken));
userRepository.save(user);
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+).
* SHA-256 is appropriate here: refresh tokens are already high-entropy random strings.
*/
private String sha256(String input) {
try {
MessageDigest digest = MessageDigest.getInstance("SHA-256");
byte[] hash = digest.digest(input.getBytes(StandardCharsets.UTF_8));
return HexFormat.of().formatHex(hash);
} catch (NoSuchAlgorithmException e) {
throw new RuntimeException("SHA-256 not available", e);
}
}
/**
* Custom authentication exception — caught by GlobalExceptionHandler.
*/
public static class AuthenticationException extends RuntimeException {
public AuthenticationException(String message) {
super(message);
}
}
}