6c66783b58
- 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
163 lines
6.4 KiB
Java
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);
|
|
}
|
|
}
|
|
}
|