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