+ * The request step always shows the same confirmation message whether or + * not the email is registered, so this flow can't be used to enumerate + * which addresses have accounts. + */ +@Controller +@RequiredArgsConstructor +@Slf4j +public class PasswordResetController { + + private static final String GENERIC_REQUEST_MESSAGE = + "If an account exists for that email, we've sent a link to reset your password."; + + private final VerificationService verificationService; + + /** Renders the "forgot password" email-entry form. */ + @GetMapping("/forgot-password") + public String forgotPassword(Model model) { + model.addAttribute("forgotPasswordDto", new ForgotPasswordDto()); + return "forgot-password"; + } + + /** Issues a reset token for the given email (if it exists) and always shows the same confirmation. */ + @PostMapping("/forgot-password") + public String processForgotPassword(@Valid @ModelAttribute("forgotPasswordDto") ForgotPasswordDto dto, + BindingResult bindingResult, + RedirectAttributes redirectAttributes) { + if (bindingResult.hasErrors()) { + return "forgot-password"; + } + + log.info("Password reset requested for email={}", dto.getEmail()); + verificationService.requestPasswordReset(dto.getEmail()); + + redirectAttributes.addFlashAttribute("successMessage", GENERIC_REQUEST_MESSAGE); + return "redirect:/sign-in"; + } + + /** Renders the "choose a new password" form for a token carried in the link. */ + @GetMapping("/reset-password") + public String resetPassword(@RequestParam String token, Model model) { + model.addAttribute("resetPasswordDto", new ResetPasswordDto().setToken(token)); + return "reset-password"; + } + + /** Redeems the token and sets the new password, or re-renders the form with an error if the token/password is invalid. */ + @PostMapping("/reset-password") + public String processResetPassword(@Valid @ModelAttribute("resetPasswordDto") ResetPasswordDto dto, + BindingResult bindingResult, + RedirectAttributes redirectAttributes, + Model model) { + if (!bindingResult.hasErrors() && !dto.getPassword().equals(dto.getRepeatPassword())) { + bindingResult.rejectValue("repeatPassword", "password.mismatch", "Passwords do not match"); + } + + if (bindingResult.hasErrors()) { + return "reset-password"; + } + + try { + verificationService.resetPassword(dto.getToken(), dto.getPassword()); + } catch (InvalidTokenException e) { + log.warn("Password reset failed: {}", e.getMessage()); + model.addAttribute("errorMessage", e.getMessage() + " Please request a new reset link."); + return "reset-password"; + } + + redirectAttributes.addFlashAttribute("successMessage", "Your password has been reset. You can now sign in."); + return "redirect:/sign-in"; + } + +} diff --git a/src/main/java/com/weatherviewer/dto/ForgotPasswordDto.java b/src/main/java/com/weatherviewer/dto/ForgotPasswordDto.java new file mode 100644 index 0000000..d127d7a --- /dev/null +++ b/src/main/java/com/weatherviewer/dto/ForgotPasswordDto.java @@ -0,0 +1,23 @@ +package com.weatherviewer.dto; + +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import lombok.experimental.Accessors; + +@Getter +@Setter +@Accessors(chain = true) +@Schema(description = "Payload for requesting a password reset email") +public class ForgotPasswordDto { + + @Schema(description = "Email address of the account to reset", example = "jane.doe@example.com", maxLength = 150) + @Email(message = "Invalid email format") + @NotBlank(message = "Email cannot be blank") + @Size(max = 150, message = "Email cannot exceed 150 characters") + private String email; + +} diff --git a/src/main/java/com/weatherviewer/dto/ResetPasswordDto.java b/src/main/java/com/weatherviewer/dto/ResetPasswordDto.java new file mode 100644 index 0000000..d34be78 --- /dev/null +++ b/src/main/java/com/weatherviewer/dto/ResetPasswordDto.java @@ -0,0 +1,37 @@ +package com.weatherviewer.dto; + +import com.weatherviewer.validation.annotation.Password; +import io.swagger.v3.oas.annotations.media.Schema; +import jakarta.validation.constraints.NotBlank; +import jakarta.validation.constraints.Size; +import lombok.Getter; +import lombok.Setter; +import lombok.ToString; +import lombok.experimental.Accessors; + +@Getter +@Setter +@ToString +@Accessors(chain = true) +@Schema(description = "Payload for redeeming a password-reset link") +public class ResetPasswordDto { + + @Schema(description = "Password reset token from the emailed link", accessMode = Schema.AccessMode.WRITE_ONLY) + @NotBlank(message = "Reset link is invalid") + @ToString.Exclude + private String token; + + @Schema(description = "New password (up to 72 characters, bcrypt-hashed server-side)", maxLength = 72) + @Password + @NotBlank(message = "Password cannot be blank") + @Size(max = 72, message = "Password cannot exceed 72 characters") + @ToString.Exclude + private String password; + + @Schema(description = "Must match `password`", maxLength = 72) + @NotBlank(message = "Repeat password cannot be blank") + @Size(max = 72, message = "Password cannot exceed 72 characters") + @ToString.Exclude + private String repeatPassword; + +} diff --git a/src/main/java/com/weatherviewer/exception/InvalidTokenException.java b/src/main/java/com/weatherviewer/exception/InvalidTokenException.java new file mode 100644 index 0000000..f3d6b81 --- /dev/null +++ b/src/main/java/com/weatherviewer/exception/InvalidTokenException.java @@ -0,0 +1,14 @@ +package com.weatherviewer.exception; + +/** + * Thrown when an email-verification or password-reset token is missing, + * of the wrong {@link com.weatherviewer.model.enums.TokenType}, already + * used, or expired. + */ +public class InvalidTokenException extends RuntimeException { + + public InvalidTokenException(String message) { + super(message); + } + +} diff --git a/src/main/java/com/weatherviewer/exception/MvcExceptionHandler.java b/src/main/java/com/weatherviewer/exception/MvcExceptionHandler.java index ddab126..513fd0d 100644 --- a/src/main/java/com/weatherviewer/exception/MvcExceptionHandler.java +++ b/src/main/java/com/weatherviewer/exception/MvcExceptionHandler.java @@ -25,7 +25,8 @@ SearchController.class, ForecastController.class, ProfileController.class, - AuthController.class + AuthController.class, + PasswordResetController.class }) @Slf4j public class MvcExceptionHandler { diff --git a/src/main/java/com/weatherviewer/mapper/UserMapper.java b/src/main/java/com/weatherviewer/mapper/UserMapper.java index caf54e5..31bb030 100644 --- a/src/main/java/com/weatherviewer/mapper/UserMapper.java +++ b/src/main/java/com/weatherviewer/mapper/UserMapper.java @@ -16,11 +16,13 @@ public interface UserMapper { /** * Builds a new {@link User} from a registration payload. New accounts - * are always created with {@code status = ACTIVE} and {@code role = USER}; - * the password field is copied as-is and must already be hashed by the - * caller before persisting. + * are always created with {@code status = PENDING} (until the owner + * confirms their email address via the verification link — see + * {@link com.weatherviewer.service.VerificationService}) and + * {@code role = USER}; the password field is copied as-is and must + * already be hashed by the caller before persisting. */ - @Mapping(target = "status", expression = "java(com.weatherviewer.model.enums.UserStatus.ACTIVE)") + @Mapping(target = "status", expression = "java(com.weatherviewer.model.enums.UserStatus.PENDING)") @Mapping(target = "role", expression = "java(com.weatherviewer.model.enums.Role.USER)") @Mapping(target = "id", ignore = true) @Mapping(target = "createdAt", ignore = true) diff --git a/src/main/java/com/weatherviewer/model/VerificationToken.java b/src/main/java/com/weatherviewer/model/VerificationToken.java new file mode 100644 index 0000000..b3d0528 --- /dev/null +++ b/src/main/java/com/weatherviewer/model/VerificationToken.java @@ -0,0 +1,57 @@ +package com.weatherviewer.model; + +import com.weatherviewer.model.enums.TokenType; +import jakarta.persistence.*; +import lombok.Getter; +import lombok.NoArgsConstructor; +import lombok.Setter; +import lombok.experimental.Accessors; +import org.hibernate.annotations.JdbcType; +import org.hibernate.dialect.PostgreSQLEnumJdbcType; + +import java.time.LocalDateTime; + +/** + * A single-use, expiring token used for either email verification or + * password reset (see {@link TokenType}). + *
+ * Tokens are opaque random strings (never the entity's own {@code id}) so + * they can be safely embedded in an emailed URL. Each token is valid until + * either {@link #expiresAt} passes or it is consumed once ({@link #used}). + */ +@Entity +@Getter +@Setter +@NoArgsConstructor +@Accessors(chain = true) +@Table(name = "verification_tokens") +public class VerificationToken extends BaseEntity { + + /** Opaque, unique, unguessable token value embedded in the emailed link. */ + @Column(unique = true, nullable = false) + private String token; + + /** The account this token grants an action for. */ + @ManyToOne(fetch = FetchType.LAZY) + @JoinColumn(name = "user_id", nullable = false) + private User user; + + /** What this token authorizes: confirming an email or resetting a password. */ + @Enumerated(EnumType.STRING) + @Column(name = "type", columnDefinition = "token_type", nullable = false) + @JdbcType(PostgreSQLEnumJdbcType.class) + private TokenType type; + + /** Moment after which the token can no longer be redeemed. */ + @Column(nullable = false) + private LocalDateTime expiresAt; + + /** Whether the token has already been redeemed; consumed tokens can never be reused. */ + private boolean used; + + /** @return {@code true} if the token is still within its validity window and has not been redeemed yet. */ + public boolean isValid() { + return !used && expiresAt.isAfter(LocalDateTime.now()); + } + +} diff --git a/src/main/java/com/weatherviewer/model/enums/TokenType.java b/src/main/java/com/weatherviewer/model/enums/TokenType.java new file mode 100644 index 0000000..3a05bb4 --- /dev/null +++ b/src/main/java/com/weatherviewer/model/enums/TokenType.java @@ -0,0 +1,18 @@ +package com.weatherviewer.model.enums; + +/** + * The purpose of a {@link com.weatherviewer.model.VerificationToken}. + *
+ * Both email verification and password reset use the same underlying
+ * token table; this enum keeps the two purposes from being interchangeable
+ * (a leaked verification link can never be used to reset a password, and
+ * vice versa).
+ */
+public enum TokenType {
+
+ /** Confirms a newly registered account's email address. */
+ EMAIL_VERIFICATION,
+ /** Authorizes a one-time password reset. */
+ PASSWORD_RESET
+
+}
diff --git a/src/main/java/com/weatherviewer/repository/VerificationTokenRepository.java b/src/main/java/com/weatherviewer/repository/VerificationTokenRepository.java
new file mode 100644
index 0000000..4868290
--- /dev/null
+++ b/src/main/java/com/weatherviewer/repository/VerificationTokenRepository.java
@@ -0,0 +1,23 @@
+package com.weatherviewer.repository;
+
+import com.weatherviewer.model.VerificationToken;
+import com.weatherviewer.model.enums.TokenType;
+import io.lettuce.core.dynamic.annotation.Param;
+import org.springframework.data.jpa.repository.JpaRepository;
+import org.springframework.data.jpa.repository.Modifying;
+import org.springframework.data.jpa.repository.Query;
+
+import java.util.Optional;
+import java.util.UUID;
+
+public interface VerificationTokenRepository extends JpaRepository
+ * A {@link DisabledException} means the account exists and the password
+ * was correct, but {@link com.weatherviewer.security.SecUser#isEnabled()}
+ * is {@code false} — in practice, an account still {@code PENDING} email
+ * verification. That case is distinguished from ordinary bad-credentials
+ * failures via an {@code unverified} query parameter, so the sign-in page
+ * can offer to resend the verification email instead of just "try again".
*/
@Component
public class CustomAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@@ -15,4 +30,23 @@ public CustomAuthFailureHandler() {
super("/sign-in-failure");
}
+ @Override
+ public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
+ AuthenticationException exception) throws IOException, ServletException {
+ if (!(exception instanceof DisabledException)) {
+ super.onAuthenticationFailure(request, response, exception);
+ return;
+ }
+
+ UriComponentsBuilder targetUrl = UriComponentsBuilder.fromPath("/sign-in-failure")
+ .queryParam("unverified", "true");
+
+ String email = request.getParameter("email");
+ if (email != null && !email.isBlank()) {
+ targetUrl.queryParam("email", email);
+ }
+
+ getRedirectStrategy().sendRedirect(request, response, targetUrl.toUriString());
+ }
+
}
diff --git a/src/main/java/com/weatherviewer/service/MailService.java b/src/main/java/com/weatherviewer/service/MailService.java
new file mode 100644
index 0000000..280e0cb
--- /dev/null
+++ b/src/main/java/com/weatherviewer/service/MailService.java
@@ -0,0 +1,16 @@
+package com.weatherviewer.service;
+
+/**
+ * Outbound transactional email. Implementations must never let a mail
+ * provider outage break the calling request (sign-up, password reset) —
+ * failures are logged and swallowed rather than propagated.
+ */
+public interface MailService {
+
+ /** Sends a new-account email containing a link to confirm the given address. */
+ void sendVerificationEmail(String to, String firstName, String verificationLink);
+
+ /** Sends a link allowing the recipient to set a new password. */
+ void sendPasswordResetEmail(String to, String firstName, String resetLink);
+
+}
diff --git a/src/main/java/com/weatherviewer/service/VerificationService.java b/src/main/java/com/weatherviewer/service/VerificationService.java
new file mode 100644
index 0000000..9d7d815
--- /dev/null
+++ b/src/main/java/com/weatherviewer/service/VerificationService.java
@@ -0,0 +1,37 @@
+package com.weatherviewer.service;
+
+import com.weatherviewer.model.User;
+
+/**
+ * Issues and redeems the one-time tokens behind email verification and
+ * password reset.
+ */
+public interface VerificationService {
+
+ /** Generates a fresh email-verification token for {@code user} and emails it to them. */
+ void sendVerificationEmail(User user);
+
+ /**
+ * Redeems an email-verification token: activates the owning account and
+ * consumes the token.
+ *
+ * @throws com.weatherviewer.exception.InvalidTokenException if the token is unknown, of the wrong type, expired, or already used
+ */
+ void confirmEmail(String token);
+
+ /**
+ * If {@code email} belongs to an account, issues a password-reset token
+ * and emails it. Does nothing (and never reveals whether the address is
+ * registered) otherwise, to avoid leaking which emails have accounts.
+ */
+ void requestPasswordReset(String email);
+
+ /**
+ * Redeems a password-reset token, setting the owning account's password
+ * to {@code newRawPassword} and consuming the token.
+ *
+ * @throws com.weatherviewer.exception.InvalidTokenException if the token is unknown, of the wrong type, expired, or already used
+ */
+ void resetPassword(String token, String newRawPassword);
+
+}
diff --git a/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java
new file mode 100644
index 0000000..be96b35
--- /dev/null
+++ b/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java
@@ -0,0 +1,72 @@
+package com.weatherviewer.service.impl;
+
+import com.weatherviewer.service.MailService;
+import lombok.extern.slf4j.Slf4j;
+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.stereotype.Service;
+
+/**
+ * {@link MailService} implementation backed by {@link JavaMailSender}.
+ *
+ * Emails are plain text on purpose (no external images/styles to fetch,
+ * nothing for a mail client to block). A misconfigured or unreachable SMTP
+ * server never bubbles up as a 500 to the user — sign-up and password
+ * reset both still succeed, they just log a warning that no email went
+ * out, so the account/token itself is never lost because of a mail outage.
+ */
+@Service
+@Slf4j
+public class MailServiceImpl implements MailService {
+
+ private final JavaMailSender mailSender;
+ private final String fromAddress;
+
+ public MailServiceImpl(JavaMailSender mailSender,
+ @Value("${app.mail.from:no-reply@weatherviewer.local}") String fromAddress) {
+ this.mailSender = mailSender;
+ this.fromAddress = fromAddress;
+ }
+
+ @Override
+ public void sendVerificationEmail(String to, String firstName, String verificationLink) {
+ String subject = "Verify your WeatherViewer account";
+ String body = "Hi " + firstName + ",\n\n"
+ + "Thanks for signing up for WeatherViewer! Please confirm your email address by opening the link below:\n\n"
+ + verificationLink + "\n\n"
+ + "This link expires in 24 hours. If you didn't create this account, you can safely ignore this email.\n\n"
+ + "— The WeatherViewer team";
+
+ send(to, subject, body);
+ }
+
+ @Override
+ public void sendPasswordResetEmail(String to, String firstName, String resetLink) {
+ String subject = "Reset your WeatherViewer password";
+ String body = "Hi " + firstName + ",\n\n"
+ + "We received a request to reset your WeatherViewer password. Open the link below to choose a new one:\n\n"
+ + resetLink + "\n\n"
+ + "This link expires in 1 hour. If you didn't request this, you can safely ignore this email — "
+ + "your password will not be changed.\n\n"
+ + "— The WeatherViewer team";
+
+ send(to, subject, body);
+ }
+
+ private void send(String to, String subject, String body) {
+ try {
+ SimpleMailMessage message = new SimpleMailMessage();
+ message.setFrom(fromAddress);
+ message.setTo(to);
+ message.setSubject(subject);
+ message.setText(body);
+ mailSender.send(message);
+ log.info("Sent email '{}' to {}", subject, to);
+ } catch (MailException e) {
+ log.warn("Failed to send email '{}' to {}: {}", subject, to, e.getMessage());
+ }
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java
new file mode 100644
index 0000000..fc60ac5
--- /dev/null
+++ b/src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java
@@ -0,0 +1,146 @@
+package com.weatherviewer.service.impl;
+
+import com.weatherviewer.exception.InvalidTokenException;
+import com.weatherviewer.model.User;
+import com.weatherviewer.model.VerificationToken;
+import com.weatherviewer.model.enums.TokenType;
+import com.weatherviewer.model.enums.UserStatus;
+import com.weatherviewer.repository.UserRepository;
+import com.weatherviewer.repository.VerificationTokenRepository;
+import com.weatherviewer.service.MailService;
+import com.weatherviewer.service.VerificationService;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.security.crypto.password.PasswordEncoder;
+import org.springframework.stereotype.Service;
+import org.springframework.transaction.annotation.Transactional;
+import org.springframework.web.util.UriComponentsBuilder;
+
+import java.security.SecureRandom;
+import java.time.LocalDateTime;
+import java.util.Base64;
+
+/**
+ * Default {@link VerificationService} implementation.
+ *
+ * Tokens are 32 bytes of {@link SecureRandom} output, URL-safe
+ * base64-encoded — long and unguessable enough to embed directly in an
+ * emailed link. Issuing a new token for a given user/purpose first
+ * invalidates any earlier unused ones of that same type, so only the most
+ * recently emailed link ever works.
+ */
+@Service
+@Slf4j
+public class VerificationServiceImpl implements VerificationService {
+
+ private static final SecureRandom SECURE_RANDOM = new SecureRandom();
+
+ private final UserRepository userRepository;
+ private final VerificationTokenRepository tokenRepository;
+ private final MailService mailService;
+ private final PasswordEncoder passwordEncoder;
+ private final String baseUrl;
+ private final long verificationTtlHours;
+ private final long passwordResetTtlHours;
+
+ public VerificationServiceImpl(UserRepository userRepository,
+ VerificationTokenRepository tokenRepository,
+ MailService mailService,
+ PasswordEncoder passwordEncoder,
+ @Value("${app.base-url:http://localhost:8080}") String baseUrl,
+ @Value("${app.verification.token-ttl-hours:24}") long verificationTtlHours,
+ @Value("${app.password-reset.token-ttl-hours:1}") long passwordResetTtlHours) {
+ this.userRepository = userRepository;
+ this.tokenRepository = tokenRepository;
+ this.mailService = mailService;
+ this.passwordEncoder = passwordEncoder;
+ this.baseUrl = baseUrl;
+ this.verificationTtlHours = verificationTtlHours;
+ this.passwordResetTtlHours = passwordResetTtlHours;
+ }
+
+ @Override
+ @Transactional
+ public void sendVerificationEmail(User user) {
+ tokenRepository.invalidateActiveTokens(user.getId(), TokenType.EMAIL_VERIFICATION);
+
+ String rawToken = issueToken(user, TokenType.EMAIL_VERIFICATION, verificationTtlHours);
+
+ String link = UriComponentsBuilder.fromUriString(baseUrl)
+ .path("/verify-email")
+ .queryParam("token", rawToken)
+ .toUriString();
+
+ mailService.sendVerificationEmail(user.getEmail(), user.getFirstName(), link);
+ }
+
+ @Override
+ @Transactional
+ public void confirmEmail(String token) {
+ VerificationToken verificationToken = redeem(token, TokenType.EMAIL_VERIFICATION);
+
+ User user = verificationToken.getUser();
+ if (user.getStatus() == UserStatus.PENDING) {
+ user.setStatus(UserStatus.ACTIVE);
+ userRepository.save(user);
+ }
+ }
+
+ @Override
+ @Transactional
+ public void requestPasswordReset(String email) {
+ userRepository.findByEmail(email).ifPresentOrElse(user -> {
+ tokenRepository.invalidateActiveTokens(user.getId(), TokenType.PASSWORD_RESET);
+
+ String rawToken = issueToken(user, TokenType.PASSWORD_RESET, passwordResetTtlHours);
+ String link = UriComponentsBuilder.fromUriString(baseUrl)
+ .path("/reset-password")
+ .queryParam("token", rawToken)
+ .toUriString();
+
+ mailService.sendPasswordResetEmail(user.getEmail(), user.getFirstName(), link);
+ }, () -> log.info("Password reset requested for unregistered email={}", email));
+ }
+
+ @Override
+ @Transactional
+ public void resetPassword(String token, String newRawPassword) {
+ VerificationToken verificationToken = redeem(token, TokenType.PASSWORD_RESET);
+
+ User user = verificationToken.getUser();
+ user.setPassword(passwordEncoder.encode(newRawPassword));
+ userRepository.save(user);
+ }
+
+ /** Generates, persists, and returns the raw (unhashed) token string to embed in the emailed link. */
+ private String issueToken(User user, TokenType type, long ttlHours) {
+ byte[] randomBytes = new byte[32];
+ SECURE_RANDOM.nextBytes(randomBytes);
+ String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
+
+ VerificationToken verificationToken = new VerificationToken()
+ .setToken(rawToken)
+ .setUser(user)
+ .setType(type)
+ .setExpiresAt(LocalDateTime.now().plusHours(ttlHours))
+ .setUsed(false);
+
+ tokenRepository.save(verificationToken);
+ return rawToken;
+ }
+
+ /** Looks up a token by value/type, validates it, and marks it used — throwing if any of that fails. */
+ private VerificationToken redeem(String rawToken, TokenType type) {
+ VerificationToken verificationToken = tokenRepository.findByTokenAndType(rawToken, type)
+ .orElseThrow(() -> new InvalidTokenException("This link is invalid."));
+
+ if (!verificationToken.isValid()) {
+ throw new InvalidTokenException("This link has expired or was already used.");
+ }
+
+ verificationToken.setUsed(true);
+ tokenRepository.save(verificationToken);
+ return verificationToken;
+ }
+
+}
diff --git a/src/main/resources/application.properties b/src/main/resources/application.properties
index 718fa96..f273614 100644
--- a/src/main/resources/application.properties
+++ b/src/main/resources/application.properties
@@ -6,6 +6,13 @@ spring.datasource.username=${SPRING_DATASOURCE_USERNAME}
spring.datasource.password=${SPRING_DATASOURCE_PASSWORD}
spring.datasource.driver-class-name=org.postgresql.Driver
+spring.datasource.hikari.keepalive-time=120000
+spring.datasource.hikari.max-lifetime=600000
+spring.datasource.hikari.idle-timeout=300000
+spring.datasource.hikari.minimum-idle=2
+spring.datasource.hikari.connection-timeout=30000
+spring.datasource.hikari.validation-timeout=5000
+
spring.jpa.open-in-view=false
spring.mvc.hiddenmethod.filter.enabled=true
spring.jpa.properties.hibernate.jdbc.time_zone=UTC
@@ -50,6 +57,23 @@ spring.data.redis.port=${SPRING_DATA_REDIS_PORT:6379}
spring.cache.type=${SPRING_CACHE_TYPE:simple}
spring.cache.redis.time-to-live=3600000
+# --- Mail (verification / password reset) ---
+spring.mail.host=${MAIL_HOST:localhost}
+spring.mail.port=${MAIL_PORT:1025}
+spring.mail.username=${MAIL_USERNAME:}
+spring.mail.password=${MAIL_PASSWORD:}
+spring.mail.properties.mail.smtp.auth=${MAIL_SMTP_AUTH:false}
+spring.mail.properties.mail.smtp.starttls.enable=${MAIL_SMTP_STARTTLS:false}
+spring.mail.properties.mail.smtp.connectiontimeout=5000
+spring.mail.properties.mail.smtp.timeout=5000
+spring.mail.properties.mail.smtp.writetimeout=5000
+app.mail.from=${MAIL_FROM:no-reply@weatherviewer.local}
+
+# --- Links embedded in emailed verification / reset tokens ---
+app.base-url=${APP_BASE_URL:http://localhost:8080}
+app.verification.token-ttl-hours=24
+app.password-reset.token-ttl-hours=1
+
# --- Actuator / health ---
management.endpoints.web.exposure.include=health
management.endpoint.health.show-details=when-authorized
diff --git a/src/main/resources/liquibase/changelog.xml b/src/main/resources/liquibase/changelog.xml
index 6fbc898..0a4206f 100644
--- a/src/main/resources/liquibase/changelog.xml
+++ b/src/main/resources/liquibase/changelog.xml
@@ -7,5 +7,6 @@
Enter the email address on your account and we'll send you a link to reset your password.
+
\ No newline at end of file
diff --git a/src/main/resources/templates/forgot-password.html b/src/main/resources/templates/forgot-password.html
new file mode 100644
index 0000000..e325bdf
--- /dev/null
+++ b/src/main/resources/templates/forgot-password.html
@@ -0,0 +1,44 @@
+
+
+
+
+
+
Forgot password
+ Weather Viewer
Current Weather
+
-
+
+
diff --git a/src/main/resources/templates/profile.html b/src/main/resources/templates/profile.html
index 8273d40..23edacd 100644
--- a/src/main/resources/templates/profile.html
+++ b/src/main/resources/templates/profile.html
@@ -66,6 +66,7 @@
Account Settings
+
diff --git a/src/main/resources/templates/reset-password.html b/src/main/resources/templates/reset-password.html
new file mode 100644
index 0000000..64aa10e
--- /dev/null
+++ b/src/main/resources/templates/reset-password.html
@@ -0,0 +1,49 @@
+
+
+
+
+
+Reset password
+
+
+ Name
+