Replace the header "Byt lösenord" link with an Inställningar menu for changing email or password. Email changes are two-step: request with password, confirmation link to the new address, then password again on confirm so a wrong inbox cannot take over the account. - Backend: EmailChangeService, V10 email_change_tokens, confirm API - Frontend: ChangeEmailPage, ConfirmEmailChangePage, header dropdown - E2E: account-settings round-trips, Mailpit verification, wrong-password guard - Flyway: V9 restore for dev DBs, CI migration checks, V10 for email tokens Co-authored-by: Cursor <cursoragent@cursor.com>
70 lines
2.7 KiB
Java
70 lines
2.7 KiB
Java
package se.bilhalsning.service;
|
|
|
|
import java.time.Instant;
|
|
import java.util.Optional;
|
|
import lombok.RequiredArgsConstructor;
|
|
import org.springframework.beans.factory.annotation.Value;
|
|
import org.springframework.stereotype.Service;
|
|
import org.springframework.transaction.annotation.Transactional;
|
|
import se.bilhalsning.entity.EmailChangeToken;
|
|
import se.bilhalsning.entity.User;
|
|
import se.bilhalsning.exception.EmailChangeTokenInvalidException;
|
|
import se.bilhalsning.repository.EmailChangeTokenRepository;
|
|
|
|
@Service
|
|
@RequiredArgsConstructor
|
|
public class EmailChangeService {
|
|
|
|
private static final long TOKEN_TTL_HOURS = 24;
|
|
|
|
private final UserService userService;
|
|
private final EmailChangeTokenRepository tokenRepository;
|
|
private final EmailService emailService;
|
|
private final PasswordResetService passwordResetService;
|
|
|
|
@Value("${app.public-base-url:http://localhost:3000}")
|
|
private String publicBaseUrl;
|
|
|
|
@Value("${app.email-change.expose-token:false}")
|
|
private boolean exposeToken;
|
|
|
|
@Transactional
|
|
public Optional<String> requestChange(String currentEmail, String password, String newEmail) {
|
|
User user = userService.authenticate(currentEmail, password);
|
|
userService.validateEmailAvailableForChange(user, newEmail);
|
|
|
|
String normalizedEmail = newEmail.toLowerCase().trim();
|
|
tokenRepository.deleteUnusedByUserId(user.getId());
|
|
|
|
String rawToken = passwordResetService.generateRawToken();
|
|
EmailChangeToken entity = new EmailChangeToken();
|
|
entity.setUser(user);
|
|
entity.setNewEmail(normalizedEmail);
|
|
entity.setTokenHash(PasswordResetService.hashToken(rawToken));
|
|
entity.setExpiresAt(Instant.now().plusSeconds(TOKEN_TTL_HOURS * 3600));
|
|
tokenRepository.save(entity);
|
|
|
|
String confirmUrl = publicBaseUrl.replaceAll("/$", "")
|
|
+ "/bekrafta-epost?token="
|
|
+ rawToken;
|
|
emailService.sendEmailChangeConfirmation(normalizedEmail, confirmUrl);
|
|
|
|
return exposeToken ? Optional.of(rawToken) : Optional.empty();
|
|
}
|
|
|
|
@Transactional
|
|
public User confirmChange(String rawToken, String password) {
|
|
EmailChangeToken token = tokenRepository
|
|
.findByTokenHashAndUsedAtIsNull(PasswordResetService.hashToken(rawToken))
|
|
.filter(t -> t.getExpiresAt().isAfter(Instant.now()))
|
|
.orElseThrow(EmailChangeTokenInvalidException::new);
|
|
|
|
User user = token.getUser();
|
|
userService.authenticate(user.getEmail(), password);
|
|
User updated = userService.applyEmailChange(user, token.getNewEmail());
|
|
token.setUsedAt(Instant.now());
|
|
tokenRepository.deleteUnusedByUserId(user.getId());
|
|
tokenRepository.save(token);
|
|
return updated;
|
|
}
|
|
}
|