diff --git a/backend/pom.xml b/backend/pom.xml index 75c722b6..ad068e07 100644 --- a/backend/pom.xml +++ b/backend/pom.xml @@ -119,6 +119,10 @@ lombok true + + org.springframework.boot + spring-boot-starter-mail + org.flywaydb flyway-core diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java index db23dca9..7b8158af 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/AsyncConfig.java @@ -6,10 +6,12 @@ import java.util.concurrent.ThreadPoolExecutor; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.scheduling.annotation.EnableAsync; +import org.springframework.scheduling.annotation.EnableScheduling; import org.springframework.scheduling.concurrent.ThreadPoolTaskExecutor; @Configuration @EnableAsync +@EnableScheduling public class AsyncConfig { @Bean public Executor taskExecutor() { diff --git a/backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java b/backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java index cb4d1a87..1390d591 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/config/SecurityConfig.java @@ -48,6 +48,10 @@ public class SecurityConfig { .authorizeHttpRequests(auth -> { // Health endpoint must be open so CI/Docker health checks work without credentials auth.requestMatchers("/actuator/health").permitAll(); + // Password reset endpoints are unauthenticated by nature + auth.requestMatchers("/api/auth/forgot-password", "/api/auth/reset-password").permitAll(); + // E2E test helper (only active under "e2e" profile) + auth.requestMatchers("/api/auth/reset-token-for-test").permitAll(); // In dev, allow unauthenticated access to the OpenAPI spec and Swagger UI if (environment.matchesProfiles("dev")) { auth.requestMatchers( diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java new file mode 100644 index 00000000..704ad8d7 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthController.java @@ -0,0 +1,37 @@ +package org.raddatz.familienarchiv.controller; + +import org.raddatz.familienarchiv.dto.ForgotPasswordRequest; +import org.raddatz.familienarchiv.dto.ResetPasswordRequest; +import org.raddatz.familienarchiv.service.PasswordResetService; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +@RestController +@RequestMapping("/api/auth") +@RequiredArgsConstructor +public class AuthController { + + private final PasswordResetService passwordResetService; + + @Value("${app.base-url:http://localhost:3000}") + private String appBaseUrl; + + @PostMapping("/forgot-password") + public ResponseEntity forgotPassword(@RequestBody ForgotPasswordRequest request) { + passwordResetService.requestReset(request.getEmail(), appBaseUrl); + // Always return 204 — never disclose whether the email exists + return ResponseEntity.noContent().build(); + } + + @PostMapping("/reset-password") + public ResponseEntity resetPassword(@RequestBody ResetPasswordRequest request) { + passwordResetService.resetPassword(request); + return ResponseEntity.noContent().build(); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthE2EController.java b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthE2EController.java new file mode 100644 index 00000000..312666e4 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/controller/AuthE2EController.java @@ -0,0 +1,33 @@ +package org.raddatz.familienarchiv.controller; + +import java.time.LocalDateTime; + +import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository; +import org.springframework.context.annotation.Profile; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.RestController; + +import lombok.RequiredArgsConstructor; + +/** + * Test-only endpoint to retrieve a password reset token by email. + * Only active under the "e2e" Spring profile. + */ +@RestController +@RequestMapping("/api/auth") +@Profile("e2e") +@RequiredArgsConstructor +public class AuthE2EController { + + private final PasswordResetTokenRepository tokenRepository; + + @GetMapping("/reset-token-for-test") + public ResponseEntity getResetTokenForTest(@RequestParam String email) { + return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now()) + .map(ResponseEntity::ok) + .orElse(ResponseEntity.notFound().build()); + } +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/ForgotPasswordRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/ForgotPasswordRequest.java new file mode 100644 index 00000000..82e8dae2 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/ForgotPasswordRequest.java @@ -0,0 +1,8 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.Data; + +@Data +public class ForgotPasswordRequest { + private String email; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/dto/ResetPasswordRequest.java b/backend/src/main/java/org/raddatz/familienarchiv/dto/ResetPasswordRequest.java new file mode 100644 index 00000000..d46a6a6b --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/dto/ResetPasswordRequest.java @@ -0,0 +1,9 @@ +package org.raddatz.familienarchiv.dto; + +import lombok.Data; + +@Data +public class ResetPasswordRequest { + private String token; + private String newPassword; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java index 7e4a1ac6..c7968d27 100644 --- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java +++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java @@ -35,6 +35,8 @@ public enum ErrorCode { UNAUTHORIZED, /** The authenticated user lacks the required permission. 403 */ FORBIDDEN, + /** The password-reset token is missing, expired, or already used. 400 */ + INVALID_RESET_TOKEN, // --- Generic --- /** Request validation failed (missing or malformed fields). 400 */ diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PasswordResetToken.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PasswordResetToken.java new file mode 100644 index 00000000..7e4d956d --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PasswordResetToken.java @@ -0,0 +1,45 @@ +package org.raddatz.familienarchiv.model; + +import java.time.LocalDateTime; +import java.util.UUID; + +import jakarta.persistence.Column; +import jakarta.persistence.Entity; +import jakarta.persistence.FetchType; +import jakarta.persistence.GeneratedValue; +import jakarta.persistence.GenerationType; +import jakarta.persistence.Id; +import jakarta.persistence.JoinColumn; +import jakarta.persistence.ManyToOne; +import jakarta.persistence.Table; +import lombok.AllArgsConstructor; +import lombok.Builder; +import lombok.Data; +import lombok.NoArgsConstructor; + +@Entity +@Table(name = "password_reset_tokens") +@Data +@NoArgsConstructor +@AllArgsConstructor +@Builder +public class PasswordResetToken { + + @Id + @GeneratedValue(strategy = GenerationType.UUID) + private UUID id; + + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private AppUser user; + + @Column(nullable = false, unique = true, length = 64) + private String token; + + @Column(name = "expires_at", nullable = false) + private LocalDateTime expiresAt; + + @Column(nullable = false) + @Builder.Default + private boolean used = false; +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/repository/PasswordResetTokenRepository.java b/backend/src/main/java/org/raddatz/familienarchiv/repository/PasswordResetTokenRepository.java new file mode 100644 index 00000000..d2c5fa28 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/repository/PasswordResetTokenRepository.java @@ -0,0 +1,22 @@ +package org.raddatz.familienarchiv.repository; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import org.raddatz.familienarchiv.model.PasswordResetToken; +import org.springframework.data.jpa.repository.JpaRepository; +import org.springframework.data.jpa.repository.Modifying; +import org.springframework.data.jpa.repository.Query; + +public interface PasswordResetTokenRepository extends JpaRepository { + + Optional findByToken(String token); + + @Query("SELECT t.token FROM PasswordResetToken t WHERE t.user.email = :email AND t.used = false AND t.expiresAt > :now ORDER BY t.expiresAt DESC LIMIT 1") + Optional findLatestActiveTokenByEmail(String email, LocalDateTime now); + + @Modifying + @Query("DELETE FROM PasswordResetToken t WHERE t.expiresAt < :now OR t.used = true") + void deleteExpiredAndUsed(LocalDateTime now); +} diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PasswordResetService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PasswordResetService.java new file mode 100644 index 00000000..6f15ddb8 --- /dev/null +++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PasswordResetService.java @@ -0,0 +1,132 @@ +package org.raddatz.familienarchiv.service; + +import java.security.SecureRandom; +import java.time.LocalDateTime; +import java.util.HexFormat; +import java.util.Optional; + +import org.raddatz.familienarchiv.dto.ResetPasswordRequest; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.exception.ErrorCode; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.PasswordResetToken; +import org.raddatz.familienarchiv.repository.AppUserRepository; +import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.beans.factory.annotation.Value; +import org.springframework.mail.MailException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.scheduling.annotation.Scheduled; +import org.springframework.security.crypto.password.PasswordEncoder; +import org.springframework.stereotype.Service; +import org.springframework.transaction.annotation.Transactional; + +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; + +@Service +@RequiredArgsConstructor +@Slf4j +public class PasswordResetService { + + private final AppUserRepository userRepository; + private final PasswordResetTokenRepository tokenRepository; + private final PasswordEncoder passwordEncoder; + + @Autowired(required = false) + private JavaMailSender mailSender; + + @Value("${app.mail.from:noreply@familienarchiv.local}") + private String mailFrom; + + private static final SecureRandom SECURE_RANDOM = new SecureRandom(); + private static final int TOKEN_EXPIRY_HOURS = 1; + + /** + * Creates a reset token for the given email address and sends it via email. + * If the email is not found, silently does nothing (prevents user enumeration). + * If no mail sender is configured, logs a warning. + */ + public void requestReset(String email, String appBaseUrl) { + Optional userOpt = userRepository.findByEmail(email); + if (userOpt.isEmpty()) { + log.debug("Password reset requested for unknown email: {}", email); + return; + } + + AppUser user = userOpt.get(); + String token = generateToken(); + + tokenRepository.save(PasswordResetToken.builder() + .user(user) + .token(token) + .expiresAt(LocalDateTime.now().plusHours(TOKEN_EXPIRY_HOURS)) + .build()); + + sendResetEmail(user.getEmail(), token, appBaseUrl); + } + + /** + * Validates the token and updates the user's password. + */ + @Transactional + public void resetPassword(ResetPasswordRequest request) { + PasswordResetToken resetToken = tokenRepository.findByToken(request.getToken()) + .orElseThrow(() -> DomainException.badRequest( + ErrorCode.INVALID_RESET_TOKEN, "Invalid or unknown reset token")); + + if (resetToken.isUsed() || resetToken.getExpiresAt().isBefore(LocalDateTime.now())) { + throw DomainException.badRequest(ErrorCode.INVALID_RESET_TOKEN, "Token expired or already used"); + } + + AppUser user = resetToken.getUser(); + user.setPassword(passwordEncoder.encode(request.getNewPassword())); + userRepository.save(user); + + resetToken.setUsed(true); + tokenRepository.save(resetToken); + } + + /** Nightly cleanup of expired and used tokens. */ + @Scheduled(cron = "0 0 3 * * *") + @Transactional + public void cleanupExpiredTokens() { + tokenRepository.deleteExpiredAndUsed(LocalDateTime.now()); + log.info("Cleaned up expired password reset tokens"); + } + + private String generateToken() { + byte[] bytes = new byte[32]; + SECURE_RANDOM.nextBytes(bytes); + return HexFormat.of().formatHex(bytes); + } + + private void sendResetEmail(String to, String token, String appBaseUrl) { + if (mailSender == null) { + log.warn("Mail sender not configured — skipping password reset email to {}", to); + return; + } + + String resetUrl = appBaseUrl + "/reset-password?token=" + token; + SimpleMailMessage message = new SimpleMailMessage(); + message.setFrom(mailFrom); + message.setTo(to); + message.setSubject("Passwort zurücksetzen — Familienarchiv"); + message.setText( + "Hallo,\n\n" + + "Sie haben eine Passwort-Zurücksetzung beantragt.\n\n" + + "Klicken Sie auf den folgenden Link, um Ihr Passwort zurückzusetzen:\n" + + resetUrl + "\n\n" + + "Der Link ist " + TOKEN_EXPIRY_HOURS + " Stunde(n) gültig.\n\n" + + "Falls Sie diese Anfrage nicht gestellt haben, ignorieren Sie diese E-Mail.\n\n" + + "Ihr Familienarchiv-Team"); + + try { + mailSender.send(message); + log.info("Password reset email sent to {}", to); + } catch (MailException e) { + log.error("Failed to send password reset email to {}: {}", to, e.getMessage()); + } + } +} diff --git a/backend/src/main/resources/application.yaml b/backend/src/main/resources/application.yaml index 5839b767..fb1237fb 100644 --- a/backend/src/main/resources/application.yaml +++ b/backend/src/main/resources/application.yaml @@ -24,6 +24,23 @@ spring: max-file-size: 50MB max-request-size: 50MB + mail: + host: ${MAIL_HOST:} + port: ${MAIL_PORT:587} + username: ${MAIL_USERNAME:} + password: ${MAIL_PASSWORD:} + properties: + mail: + smtp: + auth: true + starttls: + enable: true + +management: + health: + mail: + enabled: false + springdoc: api-docs: enabled: false @@ -38,6 +55,11 @@ app: bucket: ${S3_BUCKET_NAME} region: ${S3_REGION} + base-url: ${APP_BASE_URL:http://localhost:3000} + + mail: + from: ${APP_MAIL_FROM:noreply@familienarchiv.local} + admin: username: ${APP_ADMIN_USERNAME:admin} password: ${APP_ADMIN_PASSWORD:admin123} diff --git a/backend/src/main/resources/db/migration/V8__add_password_reset_tokens.sql b/backend/src/main/resources/db/migration/V8__add_password_reset_tokens.sql new file mode 100644 index 00000000..a110afb9 --- /dev/null +++ b/backend/src/main/resources/db/migration/V8__add_password_reset_tokens.sql @@ -0,0 +1,10 @@ +CREATE TABLE password_reset_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 TIMESTAMP NOT NULL, + used BOOLEAN NOT NULL DEFAULT FALSE, + created_at TIMESTAMP NOT NULL DEFAULT now() +); + +CREATE INDEX idx_prt_token ON password_reset_tokens(token); diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PasswordResetServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PasswordResetServiceTest.java new file mode 100644 index 00000000..762efb9c --- /dev/null +++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PasswordResetServiceTest.java @@ -0,0 +1,126 @@ +package org.raddatz.familienarchiv.service; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.ArgumentMatchers.argThat; +import static org.mockito.Mockito.never; +import static org.mockito.Mockito.verify; +import static org.mockito.Mockito.when; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.InjectMocks; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.raddatz.familienarchiv.dto.ResetPasswordRequest; +import org.raddatz.familienarchiv.exception.DomainException; +import org.raddatz.familienarchiv.model.AppUser; +import org.raddatz.familienarchiv.model.PasswordResetToken; +import org.raddatz.familienarchiv.repository.AppUserRepository; +import org.raddatz.familienarchiv.repository.PasswordResetTokenRepository; +import org.springframework.mail.javamail.JavaMailSender; +import org.springframework.security.crypto.password.PasswordEncoder; + +@ExtendWith(MockitoExtension.class) +class PasswordResetServiceTest { + + @Mock AppUserRepository userRepository; + @Mock PasswordResetTokenRepository tokenRepository; + @Mock PasswordEncoder passwordEncoder; + @Mock JavaMailSender mailSender; + @InjectMocks PasswordResetService service; + + private AppUser makeUser(String email) { + return AppUser.builder() + .id(UUID.randomUUID()) + .username("testuser") + .email(email) + .password("hashed") + .build(); + } + + // ─── requestReset ───────────────────────────────────────────────────────── + + @Test + void requestReset_savesTokenForKnownEmail() { + AppUser user = makeUser("user@example.com"); + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + + service.requestReset("user@example.com", "http://localhost:3000"); + + verify(tokenRepository).save(argThat(t -> + t.getUser().equals(user) + && t.getToken().length() == 64 + && !t.isUsed())); + } + + @Test + void requestReset_doesNothingForUnknownEmail() { + when(userRepository.findByEmail("ghost@example.com")).thenReturn(Optional.empty()); + + service.requestReset("ghost@example.com", "http://localhost:3000"); + + verify(tokenRepository, never()).save(any()); + } + + // ─── resetPassword ──────────────────────────────────────────────────────── + + @Test + void resetPassword_updatesPasswordForValidToken() { + AppUser user = makeUser("user@example.com"); + PasswordResetToken token = PasswordResetToken.builder() + .id(UUID.randomUUID()) + .token("validtoken123") + .user(user) + .expiresAt(LocalDateTime.now().plusHours(1)) + .used(false) + .build(); + when(tokenRepository.findByToken("validtoken123")).thenReturn(Optional.of(token)); + when(passwordEncoder.encode("newpass")).thenReturn("hashed-newpass"); + + ResetPasswordRequest req = new ResetPasswordRequest(); + req.setToken("validtoken123"); + req.setNewPassword("newpass"); + service.resetPassword(req); + + verify(passwordEncoder).encode("newpass"); + verify(userRepository).save(argThat(u -> u.getPassword().equals("hashed-newpass"))); + assertThat(token.isUsed()).isTrue(); + } + + @Test + void resetPassword_throwsForExpiredToken() { + AppUser user = makeUser("user@example.com"); + PasswordResetToken token = PasswordResetToken.builder() + .token("expiredtoken") + .user(user) + .expiresAt(LocalDateTime.now().minusMinutes(1)) + .used(false) + .build(); + when(tokenRepository.findByToken("expiredtoken")).thenReturn(Optional.of(token)); + + ResetPasswordRequest req = new ResetPasswordRequest(); + req.setToken("expiredtoken"); + req.setNewPassword("newpass"); + + assertThatThrownBy(() -> service.resetPassword(req)) + .isInstanceOf(DomainException.class); + } + + @Test + void resetPassword_throwsForUnknownToken() { + when(tokenRepository.findByToken("nosuchtoken")).thenReturn(Optional.empty()); + + ResetPasswordRequest req = new ResetPasswordRequest(); + req.setToken("nosuchtoken"); + req.setNewPassword("newpass"); + + assertThatThrownBy(() -> service.resetPassword(req)) + .isInstanceOf(DomainException.class); + } +}