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); } } }