feat(backend): add password reset via email
- Add PasswordResetToken entity, repository (Flyway V8 migration)
- PasswordResetService: token generation, validation, nightly cleanup
- AuthController: POST /api/auth/forgot-password and /api/auth/reset-password (both permitAll)
- AuthE2EController (@Profile("e2e")): GET /api/auth/reset-token-for-test for CI testing
- spring-boot-starter-mail dependency; JavaMailSender optional (@Autowired required=false)
- mail health indicator disabled; mail config via MAIL_HOST/PORT/USERNAME/PASSWORD env vars
- 5 unit tests written TDD-style (all pass)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -119,6 +119,10 @@
|
||||
<artifactId>lombok</artifactId>
|
||||
<optional>true</optional>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.springframework.boot</groupId>
|
||||
<artifactId>spring-boot-starter-mail</artifactId>
|
||||
</dependency>
|
||||
<dependency>
|
||||
<groupId>org.flywaydb</groupId>
|
||||
<artifactId>flyway-core</artifactId>
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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<Void> 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<Void> resetPassword(@RequestBody ResetPasswordRequest request) {
|
||||
passwordResetService.resetPassword(request);
|
||||
return ResponseEntity.noContent().build();
|
||||
}
|
||||
}
|
||||
@@ -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<String> getResetTokenForTest(@RequestParam String email) {
|
||||
return tokenRepository.findLatestActiveTokenByEmail(email, LocalDateTime.now())
|
||||
.map(ResponseEntity::ok)
|
||||
.orElse(ResponseEntity.notFound().build());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ForgotPasswordRequest {
|
||||
private String email;
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
package org.raddatz.familienarchiv.dto;
|
||||
|
||||
import lombok.Data;
|
||||
|
||||
@Data
|
||||
public class ResetPasswordRequest {
|
||||
private String token;
|
||||
private String newPassword;
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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<PasswordResetToken, UUID> {
|
||||
|
||||
Optional<PasswordResetToken> 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<String> findLatestActiveTokenByEmail(String email, LocalDateTime now);
|
||||
|
||||
@Modifying
|
||||
@Query("DELETE FROM PasswordResetToken t WHERE t.expiresAt < :now OR t.used = true")
|
||||
void deleteExpiredAndUsed(LocalDateTime now);
|
||||
}
|
||||
@@ -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<AppUser> 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());
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user