From 65205ea3232b9d75f65fbfa9552cc33a631b50b1 Mon Sep 17 00:00:00 2001 From: podlLev Date: Tue, 21 Jul 2026 11:55:35 +0300 Subject: [PATCH 1/5] feat(ui): add dark mode toggle and theme styles across pages --- src/main/resources/static/css/theme.css | 62 +++++++++++++++++++ src/main/resources/static/js/theme-init.js | 17 +++++ src/main/resources/static/js/theme-toggle.js | 41 ++++++++++++ src/main/resources/templates/forecast.html | 1 + .../resources/templates/fragments/common.html | 9 ++- .../resources/templates/header-component.html | 7 ++- src/main/resources/templates/home.html | 11 ++-- src/main/resources/templates/profile.html | 1 + src/main/resources/templates/search.html | 1 + 9 files changed, 141 insertions(+), 9 deletions(-) create mode 100644 src/main/resources/static/css/theme.css create mode 100644 src/main/resources/static/js/theme-init.js create mode 100644 src/main/resources/static/js/theme-toggle.js diff --git a/src/main/resources/static/css/theme.css b/src/main/resources/static/css/theme.css new file mode 100644 index 0000000..dfa4004 --- /dev/null +++ b/src/main/resources/static/css/theme.css @@ -0,0 +1,62 @@ +/* + * Dark-mode support, layered on top of Bootstrap 5.3's built-in + * `data-bs-theme` color modes. Bootstrap's own components (cards, forms, + * navbars, toasts, etc.) already re-theme themselves off `data-bs-theme`; + * the overrides below only cover this app's custom, non-Bootstrap classes + * (the auth pages' `.bg-image`/`.form`, and the toast font import) that + * don't automatically follow the color mode. + */ + +body { + transition: background-color 0.15s ease-in-out, color 0.15s ease-in-out; +} + +/* Auth pages (sign-in / sign-up / forgot / reset password) */ +[data-bs-theme="dark"] .bg-image { + filter: brightness(0.45) saturate(0.9); +} + +[data-bs-theme="dark"] .form { + background-color: rgba(33, 37, 41, 0.92); + color: var(--bs-body-color); +} + +[data-bs-theme="dark"] .form .text-muted { + color: #adb5bd !important; +} + +/* Toasts (fragments/alerts.html) render on a light card regardless of page theme by design. + Text color alone isn't enough here — Bootstrap's dark-mode .toast background is dark by + default, so without forcing the background too, this dark (#222) text sits on a dark card. */ +[data-bs-theme="dark"] .toast { + --bs-toast-bg: #fff; +} + +[data-bs-theme="dark"] .toast-body, +[data-bs-theme="dark"] .toast-header { + color: #222; + background-color: #fff; +} + +/* Weather cards (home.html) — fixed min-height so cards line up visually + even when description length differs (e.g. "Overcast clouds" vs + "Moderate rain"), and regardless of which flex line they wrap onto. */ +.weather-card { + width: 38rem; + min-height: 650px; +} + +/* Theme toggle button in the header */ +.theme-toggle-btn { + border: none; + background: transparent; + font-size: 1.25rem; + line-height: 1; + color: var(--bs-body-color); + padding: 0.375rem 0.5rem; + border-radius: 0.5rem; +} + +.theme-toggle-btn:hover { + background-color: var(--bs-secondary-bg); +} \ No newline at end of file diff --git a/src/main/resources/static/js/theme-init.js b/src/main/resources/static/js/theme-init.js new file mode 100644 index 0000000..6fc59aa --- /dev/null +++ b/src/main/resources/static/js/theme-init.js @@ -0,0 +1,17 @@ +/* + * Runs synchronously in , before the body is parsed, so the page + * never flashes the wrong color mode. Precedence: an explicit choice saved + * by theme-toggle.js, then the OS-level preference, then light. + */ +(function () { + try { + var saved = window.localStorage.getItem('theme'); + var theme = saved === 'dark' || saved === 'light' + ? saved + : (window.matchMedia && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'); + + document.documentElement.setAttribute('data-bs-theme', theme); + } catch (e) { + document.documentElement.setAttribute('data-bs-theme', 'light'); + } +})(); \ No newline at end of file diff --git a/src/main/resources/static/js/theme-toggle.js b/src/main/resources/static/js/theme-toggle.js new file mode 100644 index 0000000..1edd33b --- /dev/null +++ b/src/main/resources/static/js/theme-toggle.js @@ -0,0 +1,41 @@ +(function () { + function syncButtonState() { + var button = document.getElementById('theme-toggle-btn'); + if (!button) return; + + var currentTheme = document.documentElement.getAttribute('data-bs-theme') || 'light'; + var icon = button.querySelector('i'); + + if (currentTheme === 'dark') { + if (icon) icon.className = 'bi bi-sun-fill'; + button.setAttribute('aria-label', 'Switch to light mode'); + button.setAttribute('title', 'Switch to light mode'); + } else { + if (icon) icon.className = 'bi bi-moon-stars-fill'; + button.setAttribute('aria-label', 'Switch to dark mode'); + button.setAttribute('title', 'Switch to dark mode'); + } + } + + if (document.readyState === 'loading') { + document.addEventListener('DOMContentLoaded', syncButtonState); + } else { + syncButtonState(); + } + + document.addEventListener('click', function (event) { + var button = event.target.closest('#theme-toggle-btn'); + if (!button) return; + + var currentTheme = document.documentElement.getAttribute('data-bs-theme') || 'light'; + var nextTheme = currentTheme === 'dark' ? 'light' : 'dark'; + + document.documentElement.setAttribute('data-bs-theme', nextTheme); + + try { + window.localStorage.setItem('theme', nextTheme); + } catch (e) { + } + syncButtonState(); + }); +})(); \ No newline at end of file diff --git a/src/main/resources/templates/forecast.html b/src/main/resources/templates/forecast.html index 4617e0f..7b291a4 100644 --- a/src/main/resources/templates/forecast.html +++ b/src/main/resources/templates/forecast.html @@ -57,6 +57,7 @@

+ \ No newline at end of file diff --git a/src/main/resources/templates/fragments/common.html b/src/main/resources/templates/fragments/common.html index 4bd217a..0933bd9 100644 --- a/src/main/resources/templates/fragments/common.html +++ b/src/main/resources/templates/fragments/common.html @@ -8,14 +8,17 @@ - + + + + - +
diff --git a/src/main/resources/templates/header-component.html b/src/main/resources/templates/header-component.html index 80b001f..b3dd6cf 100644 --- a/src/main/resources/templates/header-component.html +++ b/src/main/resources/templates/header-component.html @@ -10,12 +10,17 @@

Weather Viewer

+
+ +
Sign in Sign up
- + Username diff --git a/src/main/resources/templates/home.html b/src/main/resources/templates/home.html index 00c6516..bb19669 100644 --- a/src/main/resources/templates/home.html +++ b/src/main/resources/templates/home.html @@ -30,8 +30,8 @@

Current Weather

-
-
+
+
@@ -103,7 +103,7 @@

+

@@ -203,8 +203,9 @@

- + + 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/search.html b/src/main/resources/templates/search.html index 2db9756..0c1b5b1 100644 --- a/src/main/resources/templates/search.html +++ b/src/main/resources/templates/search.html @@ -45,6 +45,7 @@
Name
+ \ No newline at end of file From 9a47a99cd96e7ea8e64bc5be97aa3e9d86aaf2b8 Mon Sep 17 00:00:00 2001 From: podlLev Date: Tue, 21 Jul 2026 11:56:09 +0300 Subject: [PATCH 2/5] chore(config): add liquibase migration and properties for verification tokens --- src/main/resources/application.properties | 24 ++++++++++ src/main/resources/liquibase/changelog.xml | 1 + .../update/05-add-verification-tokens.xml | 46 +++++++++++++++++++ src/test/resources/application.properties | 4 ++ 4 files changed, 75 insertions(+) create mode 100644 src/main/resources/liquibase/update/05-add-verification-tokens.xml 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 @@ + diff --git a/src/main/resources/liquibase/update/05-add-verification-tokens.xml b/src/main/resources/liquibase/update/05-add-verification-tokens.xml new file mode 100644 index 0000000..1d2a632 --- /dev/null +++ b/src/main/resources/liquibase/update/05-add-verification-tokens.xml @@ -0,0 +1,46 @@ + + + + + + CREATE TYPE token_type AS ENUM ('EMAIL_VERIFICATION','PASSWORD_RESET'); + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index bc0b038..a674d26 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -18,3 +18,7 @@ weather.api.key=${WEATHER_API_KEY} spring.data.redis.host=localhost spring.data.redis.port=6379 spring.cache.type=simple + +# --- Mail --- +spring.mail.host=localhost +spring.mail.port=1025 From 0b3444c7579d4fd8cdd370dfcb64fdb347feedce Mon Sep 17 00:00:00 2001 From: podlLev Date: Tue, 21 Jul 2026 11:57:37 +0300 Subject: [PATCH 3/5] feat(auth): create VerificationToken entity, repository, DTOs, and exceptions --- .../weatherviewer/dto/ForgotPasswordDto.java | 23 +++ .../weatherviewer/dto/ResetPasswordDto.java | 37 +++++ .../exception/InvalidTokenException.java | 14 ++ .../model/VerificationToken.java | 57 ++++++++ .../weatherviewer/model/enums/TokenType.java | 18 +++ .../VerificationTokenRepository.java | 23 +++ .../dto/ForgotPasswordDtoTest.java | 59 ++++++++ .../dto/ResetPasswordDtoTest.java | 128 +++++++++++++++++ .../exception/InvalidTokenExceptionTest.java | 29 ++++ .../model/VerificationTokenTest.java | 57 ++++++++ .../model/enums/TokenTypeTest.java | 31 +++++ .../VerificationTokenRepositoryTest.java | 131 ++++++++++++++++++ 12 files changed, 607 insertions(+) create mode 100644 src/main/java/com/weatherviewer/dto/ForgotPasswordDto.java create mode 100644 src/main/java/com/weatherviewer/dto/ResetPasswordDto.java create mode 100644 src/main/java/com/weatherviewer/exception/InvalidTokenException.java create mode 100644 src/main/java/com/weatherviewer/model/VerificationToken.java create mode 100644 src/main/java/com/weatherviewer/model/enums/TokenType.java create mode 100644 src/main/java/com/weatherviewer/repository/VerificationTokenRepository.java create mode 100644 src/test/java/com/weatherviewer/dto/ForgotPasswordDtoTest.java create mode 100644 src/test/java/com/weatherviewer/dto/ResetPasswordDtoTest.java create mode 100644 src/test/java/com/weatherviewer/exception/InvalidTokenExceptionTest.java create mode 100644 src/test/java/com/weatherviewer/model/VerificationTokenTest.java create mode 100644 src/test/java/com/weatherviewer/model/enums/TokenTypeTest.java create mode 100644 src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryTest.java 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/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 { + + Optional findByTokenAndType(String token, TokenType type); + + /** Invalidates any earlier, still-unused tokens of the given type for a user before a fresh one is issued. */ + @Modifying + @Query("update VerificationToken t set t.used = true " + + "where t.user.id = :userId and t.type = :type and t.used = false") + void invalidateActiveTokens(@Param("userId") UUID userId, @Param("type") TokenType type); + +} diff --git a/src/test/java/com/weatherviewer/dto/ForgotPasswordDtoTest.java b/src/test/java/com/weatherviewer/dto/ForgotPasswordDtoTest.java new file mode 100644 index 0000000..6d8ab5a --- /dev/null +++ b/src/test/java/com/weatherviewer/dto/ForgotPasswordDtoTest.java @@ -0,0 +1,59 @@ +package com.weatherviewer.dto; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.weatherviewer.dto.helper.ValidatorTestFactory.assertFieldHasViolation; +import static com.weatherviewer.dto.helper.ValidatorTestFactory.assertNoViolations; + +class ForgotPasswordDtoTest { + + private Validator validator; + + @BeforeEach + void setUp() { + validator = Validation.buildDefaultValidatorFactory().getValidator(); + } + + private ForgotPasswordDto validDto() { + return new ForgotPasswordDto().setEmail("john@example.com"); + } + + @Test + void valid_dto_hasNoViolations() { + assertNoViolations(validator, validDto()); + } + + @Test + void email_null_failsValidation() { + ForgotPasswordDto dto = validDto().setEmail(null); + assertFieldHasViolation(validator, dto, "email"); + } + + @Test + void email_blank_failsValidation() { + ForgotPasswordDto dto = validDto().setEmail(""); + assertFieldHasViolation(validator, dto, "email"); + } + + @Test + void email_whitespaceOnly_failsValidation() { + ForgotPasswordDto dto = validDto().setEmail(" "); + assertFieldHasViolation(validator, dto, "email"); + } + + @Test + void email_invalidFormat_failsValidation() { + ForgotPasswordDto dto = validDto().setEmail("not-an-email"); + assertFieldHasViolation(validator, dto, "email"); + } + + @Test + void email_exceedsMaxLength_failsValidation() { + ForgotPasswordDto dto = validDto().setEmail("a".repeat(145) + "@x.com"); + assertFieldHasViolation(validator, dto, "email"); + } + +} diff --git a/src/test/java/com/weatherviewer/dto/ResetPasswordDtoTest.java b/src/test/java/com/weatherviewer/dto/ResetPasswordDtoTest.java new file mode 100644 index 0000000..4087e86 --- /dev/null +++ b/src/test/java/com/weatherviewer/dto/ResetPasswordDtoTest.java @@ -0,0 +1,128 @@ +package com.weatherviewer.dto; + +import jakarta.validation.Validation; +import jakarta.validation.Validator; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; + +import static com.weatherviewer.dto.helper.ValidatorTestFactory.assertFieldHasViolation; +import static com.weatherviewer.dto.helper.ValidatorTestFactory.assertNoViolations; +import static org.assertj.core.api.Assertions.assertThat; + +class ResetPasswordDtoTest { + + private Validator validator; + + @BeforeEach + void setUp() { + validator = Validation.buildDefaultValidatorFactory().getValidator(); + } + + private ResetPasswordDto validDto() { + return new ResetPasswordDto() + .setToken("raw-token") + .setPassword("Secure1@") + .setRepeatPassword("Secure1@"); + } + + @Test + void valid_dto_hasNoViolations() { + assertNoViolations(validator, validDto()); + } + + @Test + void token_null_failsValidation() { + ResetPasswordDto dto = validDto().setToken(null); + assertFieldHasViolation(validator, dto, "token"); + } + + @Test + void token_blank_failsValidation() { + ResetPasswordDto dto = validDto().setToken(""); + assertFieldHasViolation(validator, dto, "token"); + } + + @Test + void password_null_failsValidation() { + ResetPasswordDto dto = validDto().setPassword(null); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_blank_failsValidation() { + ResetPasswordDto dto = validDto().setPassword(""); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_noUpperCase_failsValidation() { + ResetPasswordDto dto = validDto().setPassword("secure1@"); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_noLowerCase_failsValidation() { + ResetPasswordDto dto = validDto().setPassword("SECURE1@"); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_noDigit_failsValidation() { + ResetPasswordDto dto = validDto().setPassword("Secure@@"); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_noSpecialChar_failsValidation() { + ResetPasswordDto dto = validDto().setPassword("Secure11"); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_tooShort_failsValidation() { + ResetPasswordDto dto = validDto().setPassword("Se1@"); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void password_exceedsMaxLength_failsValidation() { + // 71 letters (upper/lower alternating) + digit + symbol = 73 chars, over the 72 max + String tooLong = "Aa".repeat(35) + "A1@"; + ResetPasswordDto dto = validDto().setPassword(tooLong); + assertFieldHasViolation(validator, dto, "password"); + } + + @Test + void repeatPassword_null_failsValidation() { + ResetPasswordDto dto = validDto().setRepeatPassword(null); + assertFieldHasViolation(validator, dto, "repeatPassword"); + } + + @Test + void repeatPassword_blank_failsValidation() { + ResetPasswordDto dto = validDto().setRepeatPassword(""); + assertFieldHasViolation(validator, dto, "repeatPassword"); + } + + @Test + void repeatPassword_exceedsMaxLength_failsValidation() { + ResetPasswordDto dto = validDto().setRepeatPassword("A".repeat(73)); + assertFieldHasViolation(validator, dto, "repeatPassword"); + } + + @Test + void mismatchedPasswords_notEnforcedByBeanValidation_onlyByControllerLogic() { + ResetPasswordDto dto = validDto().setRepeatPassword("Different1@"); + assertNoViolations(validator, dto); + } + + @Test + void toString_excludesTokenAndPasswordFields() { + String toString = validDto().toString(); + + assertThat(toString) + .doesNotContain("raw-token") + .doesNotContain("Secure1@"); + } + +} diff --git a/src/test/java/com/weatherviewer/exception/InvalidTokenExceptionTest.java b/src/test/java/com/weatherviewer/exception/InvalidTokenExceptionTest.java new file mode 100644 index 0000000..62ed071 --- /dev/null +++ b/src/test/java/com/weatherviewer/exception/InvalidTokenExceptionTest.java @@ -0,0 +1,29 @@ +package com.weatherviewer.exception; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; + +class InvalidTokenExceptionTest { + + @Test + void constructor_setsMessage() { + InvalidTokenException ex = new InvalidTokenException("This link is invalid."); + assertThat(ex.getMessage()).isEqualTo("This link is invalid."); + } + + @Test + void isInstanceOf_RuntimeException() { + InvalidTokenException ex = new InvalidTokenException("This link is invalid."); + assertThat(ex).isInstanceOf(RuntimeException.class); + } + + @Test + void thrown_caughtAsRuntimeException() { + assertThatThrownBy(() -> { throw new InvalidTokenException("This link has expired or was already used."); }) + .isInstanceOf(RuntimeException.class) + .hasMessage("This link has expired or was already used."); + } + +} diff --git a/src/test/java/com/weatherviewer/model/VerificationTokenTest.java b/src/test/java/com/weatherviewer/model/VerificationTokenTest.java new file mode 100644 index 0000000..e9388fa --- /dev/null +++ b/src/test/java/com/weatherviewer/model/VerificationTokenTest.java @@ -0,0 +1,57 @@ +package com.weatherviewer.model; + +import com.weatherviewer.model.enums.TokenType; +import org.junit.jupiter.api.Test; + +import java.time.LocalDateTime; + +import static org.junit.jupiter.api.Assertions.assertFalse; +import static org.junit.jupiter.api.Assertions.assertTrue; + +class VerificationTokenTest { + + private VerificationToken token(boolean used, LocalDateTime expiresAt) { + return new VerificationToken() + .setToken("raw-token") + .setType(TokenType.EMAIL_VERIFICATION) + .setUsed(used) + .setExpiresAt(expiresAt); + } + + @Test + void isValid_notUsedAndNotExpired_returnsTrue() { + VerificationToken token = token(false, LocalDateTime.now().plusHours(1)); + + assertTrue(token.isValid()); + } + + @Test + void isValid_usedButNotExpired_returnsFalse() { + VerificationToken token = token(true, LocalDateTime.now().plusHours(1)); + + assertFalse(token.isValid()); + } + + @Test + void isValid_notUsedButExpired_returnsFalse() { + VerificationToken token = token(false, LocalDateTime.now().minusHours(1)); + + assertFalse(token.isValid()); + } + + @Test + void isValid_usedAndExpired_returnsFalse() { + VerificationToken token = token(true, LocalDateTime.now().minusHours(1)); + + assertFalse(token.isValid()); + } + + @Test + void isValid_expiresExactlyNow_returnsFalse() { + LocalDateTime now = LocalDateTime.now(); + VerificationToken token = token(false, now); + + assertFalse(token.isValid()); + } + +} diff --git a/src/test/java/com/weatherviewer/model/enums/TokenTypeTest.java b/src/test/java/com/weatherviewer/model/enums/TokenTypeTest.java new file mode 100644 index 0000000..c16f651 --- /dev/null +++ b/src/test/java/com/weatherviewer/model/enums/TokenTypeTest.java @@ -0,0 +1,31 @@ +package com.weatherviewer.model.enums; + +import org.junit.jupiter.api.Test; + +import static org.assertj.core.api.Assertions.assertThat; + +class TokenTypeTest { + + @Test + void values_containsExactlyEmailVerificationAndPasswordReset() { + assertThat(TokenType.values()) + .containsExactly(TokenType.EMAIL_VERIFICATION, TokenType.PASSWORD_RESET); + } + + @Test + void valueOf_emailVerification_returnsMatchingConstant() { + assertThat(TokenType.valueOf("EMAIL_VERIFICATION")).isEqualTo(TokenType.EMAIL_VERIFICATION); + } + + @Test + void valueOf_passwordReset_returnsMatchingConstant() { + assertThat(TokenType.valueOf("PASSWORD_RESET")).isEqualTo(TokenType.PASSWORD_RESET); + } + + @Test + void valueOf_unknownConstant_throwsIllegalArgumentException() { + assertThat(org.junit.jupiter.api.Assertions.assertThrows(IllegalArgumentException.class, + () -> TokenType.valueOf("NOT_A_TYPE"))).hasMessageContaining("NOT_A_TYPE"); + } + +} diff --git a/src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryTest.java b/src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryTest.java new file mode 100644 index 0000000..08de6de --- /dev/null +++ b/src/test/java/com/weatherviewer/repository/VerificationTokenRepositoryTest.java @@ -0,0 +1,131 @@ +package com.weatherviewer.repository; + +import com.weatherviewer.model.User; +import com.weatherviewer.model.VerificationToken; +import com.weatherviewer.model.enums.Role; +import com.weatherviewer.model.enums.TokenType; +import com.weatherviewer.model.enums.UserStatus; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.orm.jpa.DataJpaTest; +import org.springframework.boot.test.autoconfigure.orm.jpa.TestEntityManager; + +import java.time.LocalDateTime; +import java.util.Optional; + +import static org.assertj.core.api.Assertions.assertThat; + +@DataJpaTest +class VerificationTokenRepositoryTest { + + @Autowired + TestEntityManager entityManager; + + @Autowired + VerificationTokenRepository tokenRepository; + + private User user; + private User otherUser; + + @BeforeEach + void setUp() { + user = entityManager.persistAndFlush(new User() + .setEmail("john@example.com") + .setFirstName("John") + .setLastName("Doe") + .setPassword("password") + .setStatus(UserStatus.PENDING) + .setRole(Role.USER)); + + otherUser = entityManager.persistAndFlush(new User() + .setEmail("jane@example.com") + .setFirstName("Jane") + .setLastName("Doe") + .setPassword("password") + .setStatus(UserStatus.PENDING) + .setRole(Role.USER)); + } + + private VerificationToken token(String value, TokenType type, User owner, boolean used, LocalDateTime expiresAt) { + return new VerificationToken() + .setToken(value) + .setType(type) + .setUser(owner) + .setUsed(used) + .setExpiresAt(expiresAt); + } + + @Test + void findByTokenAndType_returnsTokenWhenExists() { + VerificationToken saved = entityManager.persistAndFlush( + token("abc123", TokenType.EMAIL_VERIFICATION, user, false, LocalDateTime.now().plusHours(24))); + + Optional result = tokenRepository.findByTokenAndType("abc123", TokenType.EMAIL_VERIFICATION); + + assertThat(result).isPresent(); + assertThat(result.get()).isEqualTo(saved); + } + + @Test + void findByTokenAndType_wrongType_returnsEmpty() { + entityManager.persistAndFlush( + token("abc123", TokenType.EMAIL_VERIFICATION, user, false, LocalDateTime.now().plusHours(24))); + + Optional result = tokenRepository.findByTokenAndType("abc123", TokenType.PASSWORD_RESET); + + assertThat(result).isEmpty(); + } + + @Test + void findByTokenAndType_unknownToken_returnsEmpty() { + Optional result = tokenRepository.findByTokenAndType("does-not-exist", TokenType.EMAIL_VERIFICATION); + + assertThat(result).isEmpty(); + } + + @Test + void invalidateActiveTokens_marksOnlyMatchingUserAndTypeAsUsed() { + VerificationToken target = entityManager.persistAndFlush( + token("target-token", TokenType.EMAIL_VERIFICATION, user, false, LocalDateTime.now().plusHours(24))); + entityManager.persistAndFlush( + token("other-type-token", TokenType.PASSWORD_RESET, user, false, LocalDateTime.now().plusHours(24))); + entityManager.persistAndFlush( + token("other-user-token", TokenType.EMAIL_VERIFICATION, otherUser, false, LocalDateTime.now().plusHours(24))); + + tokenRepository.invalidateActiveTokens(user.getId(), TokenType.EMAIL_VERIFICATION); + entityManager.clear(); + + VerificationToken reloaded = tokenRepository.findById(target.getId()).orElseThrow(); + assertThat(reloaded.isUsed()).isTrue(); + + VerificationToken otherType = tokenRepository.findByTokenAndType("other-type-token", TokenType.PASSWORD_RESET).orElseThrow(); + assertThat(otherType.isUsed()).isFalse(); + + VerificationToken otherUserToken = tokenRepository.findByTokenAndType("other-user-token", TokenType.EMAIL_VERIFICATION).orElseThrow(); + assertThat(otherUserToken.isUsed()).isFalse(); + } + + @Test + void invalidateActiveTokens_alreadyUsedToken_isLeftUnchanged() { + VerificationToken used = entityManager.persistAndFlush( + token("already-used", TokenType.EMAIL_VERIFICATION, user, true, LocalDateTime.now().plusHours(24))); + + tokenRepository.invalidateActiveTokens(user.getId(), TokenType.EMAIL_VERIFICATION); + entityManager.clear(); + + VerificationToken reloaded = tokenRepository.findById(used.getId()).orElseThrow(); + assertThat(reloaded.isUsed()).isTrue(); + } + + @Test + void save_duplicateTokenValue_violatesUniqueConstraint() { + entityManager.persistAndFlush( + token("duplicate", TokenType.EMAIL_VERIFICATION, user, false, LocalDateTime.now().plusHours(24))); + + org.junit.jupiter.api.Assertions.assertThrows(Exception.class, () -> + entityManager.persistAndFlush( + token("duplicate", TokenType.PASSWORD_RESET, otherUser, false, LocalDateTime.now().plusHours(1)))); + } + +} From 4b834fe8e0770b23a80c8ac4eb39712fb0573223 Mon Sep 17 00:00:00 2001 From: podlLev Date: Tue, 21 Jul 2026 11:58:18 +0300 Subject: [PATCH 4/5] feat(auth): implement MailService and VerificationService --- .../com/weatherviewer/mapper/UserMapper.java | 10 +- .../weatherviewer/service/MailService.java | 16 + .../service/VerificationService.java | 37 +++ .../service/impl/MailServiceImpl.java | 72 +++++ .../service/impl/VerificationServiceImpl.java | 146 +++++++++ .../weatherviewer/mapper/UserMapperTest.java | 4 +- .../service/impl/MailServiceImplTest.java | 112 +++++++ .../impl/VerificationServiceImplTest.java | 299 ++++++++++++++++++ 8 files changed, 690 insertions(+), 6 deletions(-) create mode 100644 src/main/java/com/weatherviewer/service/MailService.java create mode 100644 src/main/java/com/weatherviewer/service/VerificationService.java create mode 100644 src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java create mode 100644 src/main/java/com/weatherviewer/service/impl/VerificationServiceImpl.java create mode 100644 src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java create mode 100644 src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java 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/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/test/java/com/weatherviewer/mapper/UserMapperTest.java b/src/test/java/com/weatherviewer/mapper/UserMapperTest.java index e29898c..012af1f 100644 --- a/src/test/java/com/weatherviewer/mapper/UserMapperTest.java +++ b/src/test/java/com/weatherviewer/mapper/UserMapperTest.java @@ -53,7 +53,7 @@ void fromRecord_mapsAllFields() { } @Test - void fromRecord_setsDefaultStatusActive() { + void fromRecord_setsDefaultStatusPending() { CreateUserDto dto = new CreateUserDto() .setFirstName("John") .setLastName("Doe") @@ -62,7 +62,7 @@ void fromRecord_setsDefaultStatusActive() { User result = mapper.fromRecord(dto); - assertThat(result.getStatus()).isEqualTo(UserStatus.ACTIVE); + assertThat(result.getStatus()).isEqualTo(UserStatus.PENDING); } @Test diff --git a/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java new file mode 100644 index 0000000..a7ed306 --- /dev/null +++ b/src/test/java/com/weatherviewer/service/impl/MailServiceImplTest.java @@ -0,0 +1,112 @@ +package com.weatherviewer.service.impl; + +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mail.MailException; +import org.springframework.mail.MailSendException; +import org.springframework.mail.SimpleMailMessage; +import org.springframework.mail.javamail.JavaMailSender; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatCode; +import static org.mockito.ArgumentMatchers.any; +import static org.mockito.Mockito.doThrow; +import static org.mockito.Mockito.verify; + +@ExtendWith(MockitoExtension.class) +class MailServiceImplTest { + + private static final String FROM_ADDRESS = "no-reply@weatherviewer.local"; + + @Mock + private JavaMailSender mailSender; + + private MailServiceImpl service; + + @BeforeEach + void setUp() { + service = new MailServiceImpl(mailSender, FROM_ADDRESS); + } + + @Test + void sendVerificationEmail_sendsMessageWithExpectedFromToAndSubject() { + service.sendVerificationEmail("john@example.com", "John", "https://weatherviewer.local/verify-email?token=abc"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender).send(captor.capture()); + + SimpleMailMessage message = captor.getValue(); + assertThat(message.getFrom()).isEqualTo(FROM_ADDRESS); + assertThat(message.getTo()).containsExactly("john@example.com"); + assertThat(message.getSubject()).isEqualTo("Verify your WeatherViewer account"); + } + + @Test + void sendVerificationEmail_bodyContainsGreetingAndLink() { + service.sendVerificationEmail("john@example.com", "John", "https://weatherviewer.local/verify-email?token=abc"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender).send(captor.capture()); + + String body = captor.getValue().getText(); + assertThat(body).contains("Hi John,"); + assertThat(body).contains("https://weatherviewer.local/verify-email?token=abc"); + assertThat(body).contains("expires in 24 hours"); + } + + @Test + void sendPasswordResetEmail_sendsMessageWithExpectedFromToAndSubject() { + service.sendPasswordResetEmail("jane@example.com", "Jane", "https://weatherviewer.local/reset-password?token=xyz"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender).send(captor.capture()); + + SimpleMailMessage message = captor.getValue(); + assertThat(message.getFrom()).isEqualTo(FROM_ADDRESS); + assertThat(message.getTo()).containsExactly("jane@example.com"); + assertThat(message.getSubject()).isEqualTo("Reset your WeatherViewer password"); + } + + @Test + void sendPasswordResetEmail_bodyContainsGreetingAndLink() { + service.sendPasswordResetEmail("jane@example.com", "Jane", "https://weatherviewer.local/reset-password?token=xyz"); + + ArgumentCaptor captor = ArgumentCaptor.forClass(SimpleMailMessage.class); + verify(mailSender).send(captor.capture()); + + String body = captor.getValue().getText(); + assertThat(body).contains("Hi Jane,"); + assertThat(body).contains("https://weatherviewer.local/reset-password?token=xyz"); + assertThat(body).contains("expires in 1 hour"); + } + + @Test + void sendVerificationEmail_mailSenderThrows_exceptionIsSwallowed() { + doThrow(new MailSendException("SMTP unreachable")).when(mailSender).send(any(SimpleMailMessage.class)); + + assertThatCode(() -> service.sendVerificationEmail("john@example.com", "John", "https://weatherviewer.local/verify")) + .doesNotThrowAnyException(); + } + + @Test + void sendPasswordResetEmail_mailSenderThrows_exceptionIsSwallowed() { + doThrow(new MailSendException("SMTP unreachable")).when(mailSender).send(any(SimpleMailMessage.class)); + + assertThatCode(() -> service.sendPasswordResetEmail("jane@example.com", "Jane", "https://weatherviewer.local/reset")) + .doesNotThrowAnyException(); + } + + @Test + void sendVerificationEmail_genericMailException_exceptionIsSwallowed() { + doThrow(new MailException("boom") { + }).when(mailSender).send(any(SimpleMailMessage.class)); + + assertThatCode(() -> service.sendVerificationEmail("john@example.com", "John", "https://weatherviewer.local/verify")) + .doesNotThrowAnyException(); + } + +} diff --git a/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java b/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java new file mode 100644 index 0000000..147a062 --- /dev/null +++ b/src/test/java/com/weatherviewer/service/impl/VerificationServiceImplTest.java @@ -0,0 +1,299 @@ +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.Role; +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 org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.ArgumentCaptor; +import org.mockito.Mock; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.security.crypto.password.PasswordEncoder; + +import java.time.LocalDateTime; +import java.util.Optional; +import java.util.UUID; + +import static org.assertj.core.api.Assertions.assertThat; +import static org.assertj.core.api.Assertions.assertThatThrownBy; +import static org.mockito.ArgumentMatchers.eq; +import static org.mockito.Mockito.*; + +@ExtendWith(MockitoExtension.class) +class VerificationServiceImplTest { + + private static final String BASE_URL = "https://weatherviewer.local"; + private static final long VERIFICATION_TTL_HOURS = 24; + private static final long PASSWORD_RESET_TTL_HOURS = 1; + + @Mock + private UserRepository userRepository; + + @Mock + private VerificationTokenRepository tokenRepository; + + @Mock + private MailService mailService; + + @Mock + private PasswordEncoder passwordEncoder; + + private VerificationServiceImpl service; + + @BeforeEach + void setUp() { + service = new VerificationServiceImpl(userRepository, tokenRepository, mailService, passwordEncoder, + BASE_URL, VERIFICATION_TTL_HOURS, PASSWORD_RESET_TTL_HOURS); + } + + private User user(UUID id) { + return (User) new User() + .setEmail("john@example.com") + .setFirstName("John") + .setLastName("Doe") + .setPassword("hashed") + .setStatus(UserStatus.PENDING) + .setRole(Role.USER) + .setId(id); + } + + private VerificationToken tokenFor(User owner, TokenType type, String value, boolean used, LocalDateTime expiresAt) { + return (VerificationToken) new VerificationToken() + .setToken(value) + .setType(type) + .setUser(owner) + .setUsed(used) + .setExpiresAt(expiresAt) + .setId(UUID.randomUUID()); + } + + @Test + void sendVerificationEmail_invalidatesPriorTokensAndPersistsNewOne() { + User user = user(UUID.randomUUID()); + + service.sendVerificationEmail(user); + + verify(tokenRepository).invalidateActiveTokens(user.getId(), TokenType.EMAIL_VERIFICATION); + + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationToken.class); + verify(tokenRepository).save(captor.capture()); + + VerificationToken saved = captor.getValue(); + assertThat(saved.getUser()).isEqualTo(user); + assertThat(saved.getType()).isEqualTo(TokenType.EMAIL_VERIFICATION); + assertThat(saved.isUsed()).isFalse(); + assertThat(saved.getToken()).isNotBlank(); + assertThat(saved.getExpiresAt()).isAfter(LocalDateTime.now().plusHours(VERIFICATION_TTL_HOURS - 1)); + assertThat(saved.getExpiresAt()).isBefore(LocalDateTime.now().plusHours(VERIFICATION_TTL_HOURS + 1)); + } + + @Test + void sendVerificationEmail_sendsMailWithLinkContainingBaseUrlAndToken() { + User user = user(UUID.randomUUID()); + + ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(VerificationToken.class); + service.sendVerificationEmail(user); + verify(tokenRepository).save(tokenCaptor.capture()); + String rawToken = tokenCaptor.getValue().getToken(); + + ArgumentCaptor linkCaptor = ArgumentCaptor.forClass(String.class); + verify(mailService).sendVerificationEmail(eq("john@example.com"), eq("John"), linkCaptor.capture()); + + String link = linkCaptor.getValue(); + assertThat(link).startsWith(BASE_URL + "/verify-email?token="); + assertThat(link).contains(rawToken); + } + + @Test + void sendVerificationEmail_generatesDifferentTokenEachCall() { + User user = user(UUID.randomUUID()); + ArgumentCaptor captor = ArgumentCaptor.forClass(VerificationToken.class); + + service.sendVerificationEmail(user); + service.sendVerificationEmail(user); + + verify(tokenRepository, times(2)).save(captor.capture()); + assertThat(captor.getAllValues().get(0).getToken()).isNotEqualTo(captor.getAllValues().get(1).getToken()); + } + + @Test + void confirmEmail_validToken_activatesPendingUserAndMarksTokenUsed() { + User user = user(UUID.randomUUID()); + VerificationToken token = tokenFor(user, TokenType.EMAIL_VERIFICATION, "raw-token", false, + LocalDateTime.now().plusHours(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.EMAIL_VERIFICATION)) + .thenReturn(Optional.of(token)); + + service.confirmEmail("raw-token"); + + assertThat(token.isUsed()).isTrue(); + assertThat(user.getStatus()).isEqualTo(UserStatus.ACTIVE); + verify(tokenRepository).save(token); + verify(userRepository).save(user); + } + + @Test + void confirmEmail_userAlreadyActive_doesNotResaveUser() { + User user = user(UUID.randomUUID()); + user.setStatus(UserStatus.ACTIVE); + VerificationToken token = tokenFor(user, TokenType.EMAIL_VERIFICATION, "raw-token", false, + LocalDateTime.now().plusHours(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.EMAIL_VERIFICATION)) + .thenReturn(Optional.of(token)); + + service.confirmEmail("raw-token"); + + assertThat(user.getStatus()).isEqualTo(UserStatus.ACTIVE); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void confirmEmail_unknownToken_throwsInvalidTokenException() { + when(tokenRepository.findByTokenAndType("bogus", TokenType.EMAIL_VERIFICATION)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.confirmEmail("bogus")) + .isInstanceOf(InvalidTokenException.class) + .hasMessage("This link is invalid."); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void confirmEmail_expiredToken_throwsInvalidTokenException() { + User user = user(UUID.randomUUID()); + VerificationToken token = tokenFor(user, TokenType.EMAIL_VERIFICATION, "raw-token", false, + LocalDateTime.now().minusMinutes(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.EMAIL_VERIFICATION)) + .thenReturn(Optional.of(token)); + + assertThatThrownBy(() -> service.confirmEmail("raw-token")) + .isInstanceOf(InvalidTokenException.class) + .hasMessage("This link has expired or was already used."); + + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void confirmEmail_alreadyUsedToken_throwsInvalidTokenException() { + User user = user(UUID.randomUUID()); + VerificationToken token = tokenFor(user, TokenType.EMAIL_VERIFICATION, "raw-token", true, + LocalDateTime.now().plusHours(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.EMAIL_VERIFICATION)) + .thenReturn(Optional.of(token)); + + assertThatThrownBy(() -> service.confirmEmail("raw-token")) + .isInstanceOf(InvalidTokenException.class) + .hasMessage("This link has expired or was already used."); + } + + @Test + void confirmEmail_passwordResetTypeToken_notFoundAsEmailVerification() { + when(tokenRepository.findByTokenAndType("raw-token", TokenType.EMAIL_VERIFICATION)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.confirmEmail("raw-token")) + .isInstanceOf(InvalidTokenException.class); + + verify(tokenRepository, never()).findByTokenAndType("raw-token", TokenType.PASSWORD_RESET); + } + + @Test + void requestPasswordReset_existingEmail_invalidatesPriorTokensAndSendsMail() { + User user = user(UUID.randomUUID()); + when(userRepository.findByEmail("john@example.com")).thenReturn(Optional.of(user)); + + service.requestPasswordReset("john@example.com"); + + verify(tokenRepository).invalidateActiveTokens(user.getId(), TokenType.PASSWORD_RESET); + + ArgumentCaptor tokenCaptor = ArgumentCaptor.forClass(VerificationToken.class); + verify(tokenRepository).save(tokenCaptor.capture()); + assertThat(tokenCaptor.getValue().getType()).isEqualTo(TokenType.PASSWORD_RESET); + assertThat(tokenCaptor.getValue().getExpiresAt()) + .isBefore(LocalDateTime.now().plusHours(PASSWORD_RESET_TTL_HOURS + 1)); + + ArgumentCaptor linkCaptor = ArgumentCaptor.forClass(String.class); + verify(mailService).sendPasswordResetEmail(eq("john@example.com"), eq("John"), linkCaptor.capture()); + assertThat(linkCaptor.getValue()).startsWith(BASE_URL + "/reset-password?token="); + } + + @Test + void requestPasswordReset_unregisteredEmail_doesNothingSilently() { + when(userRepository.findByEmail("nobody@example.com")).thenReturn(Optional.empty()); + + service.requestPasswordReset("nobody@example.com"); + + verify(tokenRepository, never()).invalidateActiveTokens(any(UUID.class), any(TokenType.class)); + verify(tokenRepository, never()).save(any(VerificationToken.class)); + verify(mailService, never()).sendPasswordResetEmail(anyString(), anyString(), anyString()); + } + + @Test + void resetPassword_validToken_encodesAndSavesNewPasswordAndMarksTokenUsed() { + User user = user(UUID.randomUUID()); + VerificationToken token = tokenFor(user, TokenType.PASSWORD_RESET, "raw-token", false, + LocalDateTime.now().plusHours(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.PASSWORD_RESET)) + .thenReturn(Optional.of(token)); + when(passwordEncoder.encode("NewSecure1@")).thenReturn("new-hashed"); + + service.resetPassword("raw-token", "NewSecure1@"); + + assertThat(token.isUsed()).isTrue(); + assertThat(user.getPassword()).isEqualTo("new-hashed"); + verify(tokenRepository).save(token); + verify(userRepository).save(user); + } + + @Test + void resetPassword_unknownToken_throwsInvalidTokenExceptionAndNeverTouchesPassword() { + when(tokenRepository.findByTokenAndType("bogus", TokenType.PASSWORD_RESET)) + .thenReturn(Optional.empty()); + + assertThatThrownBy(() -> service.resetPassword("bogus", "NewSecure1@")) + .isInstanceOf(InvalidTokenException.class) + .hasMessage("This link is invalid."); + + verify(passwordEncoder, never()).encode(anyString()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void resetPassword_expiredToken_throwsInvalidTokenExceptionAndNeverTouchesPassword() { + User user = user(UUID.randomUUID()); + VerificationToken token = tokenFor(user, TokenType.PASSWORD_RESET, "raw-token", false, + LocalDateTime.now().minusMinutes(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.PASSWORD_RESET)) + .thenReturn(Optional.of(token)); + + assertThatThrownBy(() -> service.resetPassword("raw-token", "NewSecure1@")) + .isInstanceOf(InvalidTokenException.class); + + verify(passwordEncoder, never()).encode(anyString()); + verify(userRepository, never()).save(any(User.class)); + } + + @Test + void resetPassword_alreadyUsedToken_throwsInvalidTokenExceptionAndNeverTouchesPassword() { + User user = user(UUID.randomUUID()); + VerificationToken token = tokenFor(user, TokenType.PASSWORD_RESET, "raw-token", true, + LocalDateTime.now().plusHours(1)); + when(tokenRepository.findByTokenAndType("raw-token", TokenType.PASSWORD_RESET)) + .thenReturn(Optional.of(token)); + + assertThatThrownBy(() -> service.resetPassword("raw-token", "NewSecure1@")) + .isInstanceOf(InvalidTokenException.class); + + verify(passwordEncoder, never()).encode(anyString()); + verify(userRepository, never()).save(any(User.class)); + } + +} From 2dbf75823d2b3640949122aebeabad0f988610ba Mon Sep 17 00:00:00 2001 From: podlLev Date: Tue, 21 Jul 2026 12:00:07 +0300 Subject: [PATCH 5/5] feat(auth): add email verification, password reset endpoints, and UI integration --- pom.xml | 4 + .../weatherviewer/config/SecurityConfig.java | 24 +-- .../controller/AuthController.java | 69 +++++++- .../controller/PasswordResetController.java | 93 +++++++++++ .../exception/MvcExceptionHandler.java | 3 +- .../security/CustomAuthFailureHandler.java | 34 ++++ .../resources/templates/forgot-password.html | 44 +++++ .../resources/templates/reset-password.html | 49 ++++++ src/main/resources/templates/sign-in.html | 10 ++ src/main/resources/templates/sign-up.html | 1 + .../controller/AuthControllerTest.java | 120 ++++++++++++++ .../PasswordResetControllerTest.java | 155 ++++++++++++++++++ .../UserRegistrationIntegrationTest.java | 2 +- .../CustomAuthFailureHandlerTest.java | 84 ++++++++++ 14 files changed, 673 insertions(+), 19 deletions(-) create mode 100644 src/main/java/com/weatherviewer/controller/PasswordResetController.java create mode 100644 src/main/resources/templates/forgot-password.html create mode 100644 src/main/resources/templates/reset-password.html create mode 100644 src/test/java/com/weatherviewer/controller/PasswordResetControllerTest.java create mode 100644 src/test/java/com/weatherviewer/security/CustomAuthFailureHandlerTest.java diff --git a/pom.xml b/pom.xml index 7437962..f37e03d 100644 --- a/pom.xml +++ b/pom.xml @@ -94,6 +94,10 @@ io.micrometer micrometer-tracing-bridge-brave + + org.springframework.boot + spring-boot-starter-mail + org.projectlombok lombok diff --git a/src/main/java/com/weatherviewer/config/SecurityConfig.java b/src/main/java/com/weatherviewer/config/SecurityConfig.java index 85c8a1a..af0cfb5 100644 --- a/src/main/java/com/weatherviewer/config/SecurityConfig.java +++ b/src/main/java/com/weatherviewer/config/SecurityConfig.java @@ -55,6 +55,8 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .authorizeHttpRequests(auth -> auth .requestMatchers( "/sign-in", "/sign-up", "/sign-in-failure", + "/verify-email", "/resend-verification", + "/forgot-password", "/reset-password", "/css/**", "/images/**", "/js/**", "/actuator/health", "/actuator/health/**" ).permitAll() @@ -89,18 +91,16 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .expiredUrl("/sign-in?expired") ) .headers(headers -> headers - .contentSecurityPolicy(csp -> csp.policyDirectives( - "default-src 'self'; " - + "script-src 'self' https://cdn.jsdelivr.net; " - + "style-src 'self' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com 'unsafe-inline'; " - + "font-src 'self' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com data:; " - + "img-src 'self' data:; " - + "connect-src 'self'; " - + "object-src 'none'; " - + "base-uri 'self'; " - + "form-action 'self'; " - + "frame-ancestors 'none'" - )) + .contentSecurityPolicy(csp -> csp + .policyDirectives( + "default-src 'self'; " + + "script-src 'self' https://cdn.jsdelivr.net; " + + "style-src 'self' 'unsafe-inline' https://cdn.jsdelivr.net https://use.fontawesome.com https://cdnjs.cloudflare.com https://fonts.googleapis.com; " + + "font-src 'self' https://cdn.jsdelivr.net https://fonts.gstatic.com; " + + "connect-src 'self' https://cdn.jsdelivr.net; " + + "img-src 'self' data: https:;" + ) + ) ) .addFilterAfter(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class); diff --git a/src/main/java/com/weatherviewer/controller/AuthController.java b/src/main/java/com/weatherviewer/controller/AuthController.java index 25619b4..a0f1eb1 100644 --- a/src/main/java/com/weatherviewer/controller/AuthController.java +++ b/src/main/java/com/weatherviewer/controller/AuthController.java @@ -1,8 +1,11 @@ package com.weatherviewer.controller; import com.weatherviewer.dto.CreateUserDto; +import com.weatherviewer.exception.InvalidTokenException; +import com.weatherviewer.model.User; import com.weatherviewer.service.LoginService; import com.weatherviewer.service.UserService; +import com.weatherviewer.service.VerificationService; import com.weatherviewer.utils.SafeRedirectUtils; import jakarta.servlet.ServletException; import jakarta.validation.Valid; @@ -17,6 +20,8 @@ import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.servlet.mvc.support.RedirectAttributes; +import java.util.UUID; + /** * Thymeleaf controller for the sign-in/sign-up pages and their form * submissions. Successful sign-up auto-logs the new user in via @@ -30,6 +35,7 @@ public class AuthController { private final UserService userService; private final LoginService loginService; + private final VerificationService verificationService; /** Renders the sign-in form, preserving a sanitized post-login redirect target if one was supplied. */ @GetMapping("/sign-in") @@ -42,8 +48,18 @@ public String signIn(@RequestParam(required = false) String redirect, Model mode /** Landing target Spring Security redirects to after a failed login attempt; re-renders sign-in with an error. */ @GetMapping("/sign-in-failure") - public String signInFailure(RedirectAttributes redirectAttributes) { - log.info("Sign-in failed"); + public String signInFailure(@RequestParam(required = false) Boolean unverified, + @RequestParam(required = false) String email, + RedirectAttributes redirectAttributes) { + log.info("Sign-in failed, unverified={}", unverified); + + if (Boolean.TRUE.equals(unverified)) { + redirectAttributes.addFlashAttribute("errorMessage", + "Please verify your email before signing in."); + return "redirect:/sign-in?unverified=true" + + (email != null && !email.isBlank() ? "&email=" + email : ""); + } + redirectAttributes.addFlashAttribute("errorMessage", "Invalid email or password. Please try again."); return "redirect:/sign-in"; } @@ -76,14 +92,19 @@ public String processSignUp(@Valid @ModelAttribute("user") CreateUserDto createU } log.info("Processing sign-up for email={}", createUserDto.getEmail()); - userService.create(createUserDto); + UUID userId = userService.create(createUserDto); + + User createdUser = userService.getEntityById(userId); + verificationService.sendVerificationEmail(createdUser); try { loginService.login(createUserDto.getEmail(), createUserDto.getPassword()); log.info("Auto login successful for email={}", createUserDto.getEmail()); } catch (ServletException e) { - log.warn("Auto login failed after sign-up for email={}", createUserDto.getEmail(), e); - redirectAttributes.addFlashAttribute("errorMessage", "Auto login failed after sign-up"); + log.info("Auto login skipped/failed after sign-up for email={} (account likely pending email verification)", + createUserDto.getEmail()); + redirectAttributes.addFlashAttribute("successMessage", + "Account created! Check your email for a link to verify your account before signing in."); return "redirect:/sign-in"; } @@ -92,4 +113,42 @@ public String processSignUp(@Valid @ModelAttribute("user") CreateUserDto createU return "redirect:" + SafeRedirectUtils.sanitize(redirect, "/"); } + /** Redeems an emailed email-verification link, activating the account, then sends the user to sign in. */ + @GetMapping("/verify-email") + public String verifyEmail(@RequestParam String token, RedirectAttributes redirectAttributes) { + try { + verificationService.confirmEmail(token); + log.info("Email verified successfully for token"); + redirectAttributes.addFlashAttribute("successMessage", "Your email has been verified. You can now sign in."); + } catch (InvalidTokenException e) { + log.warn("Email verification failed: {}", e.getMessage()); + redirectAttributes.addFlashAttribute("errorMessage", + e.getMessage() + " Please request a new verification email."); + } + return "redirect:/sign-in"; + } + + /** + * Re-sends the verification email for an account that hasn't confirmed + * its address yet. Always shows the same confirmation message + * regardless of whether the email exists or is already verified, so + * this can't be used to enumerate registered addresses. + */ + @PostMapping("/resend-verification") + public String resendVerification(@RequestParam String email, RedirectAttributes redirectAttributes) { + log.info("Resend verification requested for email={}", email); + try { + User user = userService.getEntityByEmail(email); + if (user.getStatus() == com.weatherviewer.model.enums.UserStatus.PENDING) { + verificationService.sendVerificationEmail(user); + } + } catch (RuntimeException e) { + log.info("Resend verification requested for unknown email={}", email); + } + + redirectAttributes.addFlashAttribute("successMessage", + "If that account needs verifying, we've sent a fresh link to its email address."); + return "redirect:/sign-in"; + } + } diff --git a/src/main/java/com/weatherviewer/controller/PasswordResetController.java b/src/main/java/com/weatherviewer/controller/PasswordResetController.java new file mode 100644 index 0000000..60b58c7 --- /dev/null +++ b/src/main/java/com/weatherviewer/controller/PasswordResetController.java @@ -0,0 +1,93 @@ +package com.weatherviewer.controller; + +import com.weatherviewer.dto.ForgotPasswordDto; +import com.weatherviewer.dto.ResetPasswordDto; +import com.weatherviewer.exception.InvalidTokenException; +import com.weatherviewer.service.VerificationService; +import jakarta.validation.Valid; +import lombok.RequiredArgsConstructor; +import lombok.extern.slf4j.Slf4j; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.validation.BindingResult; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.ModelAttribute; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.servlet.mvc.support.RedirectAttributes; + +/** + * Thymeleaf controller for the "forgot your password" flow: requesting a + * reset link by email, then redeeming that link to set a new password. + *

+ * 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/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/security/CustomAuthFailureHandler.java b/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java index 73c9d8f..29ccbbf 100644 --- a/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java +++ b/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java @@ -1,12 +1,27 @@ package com.weatherviewer.security; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.core.AuthenticationException; import org.springframework.security.web.authentication.SimpleUrlAuthenticationFailureHandler; import org.springframework.stereotype.Component; +import org.springframework.web.util.UriComponentsBuilder; + +import java.io.IOException; /** * Redirects failed sign-in attempts to a dedicated {@code /sign-in-failure} * page instead of Spring Security's default behavior of re-rendering the * login page with a generic query parameter. + *

+ * 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/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

+

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/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

+ +
+ +
+ + +
+
+
+ + +
+
+
+ + +
+
+
+
+
+
+ + + + + + + \ No newline at end of file diff --git a/src/main/resources/templates/sign-in.html b/src/main/resources/templates/sign-in.html index 4857a56..2cf9ef1 100644 --- a/src/main/resources/templates/sign-in.html +++ b/src/main/resources/templates/sign-in.html @@ -26,6 +26,9 @@

Sign in

+
@@ -36,6 +39,12 @@

Sign in

+ +
+

Didn't get a verification email?

+ + +

@@ -45,6 +54,7 @@

Sign in

+ \ No newline at end of file diff --git a/src/main/resources/templates/sign-up.html b/src/main/resources/templates/sign-up.html index 2fae285..e149a90 100644 --- a/src/main/resources/templates/sign-up.html +++ b/src/main/resources/templates/sign-up.html @@ -56,6 +56,7 @@

Sign up

+ \ No newline at end of file diff --git a/src/test/java/com/weatherviewer/controller/AuthControllerTest.java b/src/test/java/com/weatherviewer/controller/AuthControllerTest.java index bbf1a71..ad8c08f 100644 --- a/src/test/java/com/weatherviewer/controller/AuthControllerTest.java +++ b/src/test/java/com/weatherviewer/controller/AuthControllerTest.java @@ -1,8 +1,12 @@ package com.weatherviewer.controller; +import com.weatherviewer.exception.InvalidTokenException; +import com.weatherviewer.exception.notfound.UserNotFoundException; +import com.weatherviewer.model.User; import com.weatherviewer.repository.UserRepository; import com.weatherviewer.service.LoginService; import com.weatherviewer.service.UserService; +import com.weatherviewer.service.VerificationService; import com.weatherviewer.validation.validator.PasswordMatchesValidator; import com.weatherviewer.validation.validator.UniqueEmailValidator; import jakarta.servlet.ServletException; @@ -34,6 +38,9 @@ class AuthControllerTest { @MockitoBean LoginService loginService; + @MockitoBean + VerificationService verificationService; + @MockitoBean UserRepository userRepository; @@ -181,4 +188,117 @@ void processSignUp_autoLoginFails_redirectsToSignIn() throws Exception { .andExpect(redirectedUrl("/sign-in")); } + @Test + @WithMockUser + void verifyEmail_validToken_verifiesAndRedirectsToSignInWithSuccess() throws Exception { + doNothing().when(verificationService).confirmEmail("valid-token"); + + mockMvc.perform(get("/verify-email").param("token", "valid-token")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")) + .andExpect(flash().attribute("successMessage", "Your email has been verified. You can now sign in.")); + + verify(verificationService).confirmEmail("valid-token"); + } + + @Test + @WithMockUser + void verifyEmail_invalidOrExpiredToken_redirectsToSignInWithError() throws Exception { + doThrow(new InvalidTokenException("Invalid or expired verification token")) + .when(verificationService).confirmEmail("invalid-token"); + + mockMvc.perform(get("/verify-email").param("token", "invalid-token")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")) + .andExpect(flash().attribute("errorMessage", + "Invalid or expired verification token Please request a new verification email.")); + + verify(verificationService).confirmEmail("invalid-token"); + } + + @Test + @WithMockUser + void resendVerification_pendingUser_sendsVerificationEmailAndRedirects() throws Exception { + User pendingUser = new User(); + pendingUser.setStatus(com.weatherviewer.model.enums.UserStatus.PENDING); + when(userService.getEntityByEmail("john@example.com")).thenReturn(pendingUser); + + mockMvc.perform(post("/resend-verification") + .with(csrf()) + .param("email", "john@example.com")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")) + .andExpect(flash().attribute("successMessage", + "If that account needs verifying, we've sent a fresh link to its email address.")); + + verify(verificationService).sendVerificationEmail(pendingUser); + } + + @Test + @WithMockUser + void resendVerification_activeUser_doesNotSendEmailAndRedirectsWithSameMessage() throws Exception { + User activeUser = new User(); + activeUser.setStatus(com.weatherviewer.model.enums.UserStatus.ACTIVE); + when(userService.getEntityByEmail("active@example.com")).thenReturn(activeUser); + + mockMvc.perform(post("/resend-verification") + .with(csrf()) + .param("email", "active@example.com")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")) + .andExpect(flash().attribute("successMessage", + "If that account needs verifying, we've sent a fresh link to its email address.")); + + verify(verificationService, never()).sendVerificationEmail(any()); + } + + @Test + @WithMockUser + void resendVerification_unknownEmail_doesNotSendEmailAndRedirectsWithSameMessage() throws Exception { + when(userService.getEntityByEmail("unknown@example.com")) + .thenThrow(new UserNotFoundException("User not found")); + + mockMvc.perform(post("/resend-verification") + .with(csrf()) + .param("email", "unknown@example.com")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")) + .andExpect(flash().attribute("successMessage", + "If that account needs verifying, we've sent a fresh link to its email address.")); + + verify(verificationService, never()).sendVerificationEmail(any()); + } + + @Test + @WithMockUser + void signInFailure_unverifiedTrue_withEmail_redirectsWithUnverifiedAndEmailQueryParam() throws Exception { + mockMvc.perform(get("/sign-in-failure") + .param("unverified", "true") + .param("email", "john@example.com")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in?unverified=true&email=john@example.com")) + .andExpect(flash().attribute("errorMessage", "Please verify your email before signing in.")); + } + + @Test + @WithMockUser + void signInFailure_unverifiedTrue_withoutEmail_redirectsWithUnverifiedQueryParamOnly() throws Exception { + mockMvc.perform(get("/sign-in-failure") + .param("unverified", "true")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in?unverified=true")) + .andExpect(flash().attribute("errorMessage", "Please verify your email before signing in.")); + } + + @Test + @WithMockUser + void signInFailure_unverifiedTrue_withBlankEmail_redirectsWithUnverifiedQueryParamOnly() throws Exception { + mockMvc.perform(get("/sign-in-failure") + .param("unverified", "true") + .param("email", " ")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in?unverified=true")) + .andExpect(flash().attribute("errorMessage", "Please verify your email before signing in.")); + } + } diff --git a/src/test/java/com/weatherviewer/controller/PasswordResetControllerTest.java b/src/test/java/com/weatherviewer/controller/PasswordResetControllerTest.java new file mode 100644 index 0000000..6e04986 --- /dev/null +++ b/src/test/java/com/weatherviewer/controller/PasswordResetControllerTest.java @@ -0,0 +1,155 @@ +package com.weatherviewer.controller; + +import com.weatherviewer.exception.InvalidTokenException; +import com.weatherviewer.service.VerificationService; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.WebMvcTest; +import org.springframework.security.test.context.support.WithMockUser; +import org.springframework.test.context.bean.override.mockito.MockitoBean; +import org.springframework.test.web.servlet.MockMvc; + +import static org.mockito.Mockito.*; +import static org.springframework.security.test.web.servlet.request.SecurityMockMvcRequestPostProcessors.csrf; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.*; + +@WebMvcTest(PasswordResetController.class) +class PasswordResetControllerTest { + + @Autowired + MockMvc mockMvc; + + @MockitoBean + VerificationService verificationService; + + @Test + @WithMockUser + void forgotPassword_returns200AndView() throws Exception { + mockMvc.perform(get("/forgot-password")) + .andExpect(status().isOk()) + .andExpect(view().name("forgot-password")) + .andExpect(model().attributeExists("forgotPasswordDto")); + } + + @Test + @WithMockUser + void processForgotPassword_validationErrors_returnsFormView() throws Exception { + mockMvc.perform(post("/forgot-password") + .with(csrf()) + .param("email", "")) + .andExpect(status().isOk()) + .andExpect(view().name("forgot-password")); + + verify(verificationService, never()).requestPasswordReset(anyString()); + } + + @Test + @WithMockUser + void processForgotPassword_invalidEmailFormat_returnsFormView() throws Exception { + mockMvc.perform(post("/forgot-password") + .with(csrf()) + .param("email", "not-an-email")) + .andExpect(status().isOk()) + .andExpect(view().name("forgot-password")); + + verify(verificationService, never()).requestPasswordReset(anyString()); + } + + @Test + @WithMockUser + void processForgotPassword_success_redirectsToSignInWithGenericMessage() throws Exception { + mockMvc.perform(post("/forgot-password") + .with(csrf()) + .param("email", "john@example.com")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")); + + verify(verificationService).requestPasswordReset("john@example.com"); + } + + @Test + @WithMockUser + void processForgotPassword_unregisteredEmail_stillShowsGenericSuccessRedirect() throws Exception { + doNothing().when(verificationService).requestPasswordReset("nobody@example.com"); + + mockMvc.perform(post("/forgot-password") + .with(csrf()) + .param("email", "nobody@example.com")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")); + + verify(verificationService).requestPasswordReset("nobody@example.com"); + } + + @Test + @WithMockUser + void resetPassword_returns200AndViewWithTokenPrefilled() throws Exception { + mockMvc.perform(get("/reset-password").param("token", "raw-token")) + .andExpect(status().isOk()) + .andExpect(view().name("reset-password")) + .andExpect(model().attributeExists("resetPasswordDto")); + } + + @Test + @WithMockUser + void processResetPassword_validationErrors_returnsFormView() throws Exception { + mockMvc.perform(post("/reset-password") + .with(csrf()) + .param("token", "raw-token") + .param("password", "") + .param("repeatPassword", "")) + .andExpect(status().isOk()) + .andExpect(view().name("reset-password")); + + verify(verificationService, never()).resetPassword(anyString(), anyString()); + } + + @Test + @WithMockUser + void processResetPassword_passwordsDoNotMatch_returnsFormView() throws Exception { + mockMvc.perform(post("/reset-password") + .with(csrf()) + .param("token", "raw-token") + .param("password", "Secure1@") + .param("repeatPassword", "Different1@")) + .andExpect(status().isOk()) + .andExpect(view().name("reset-password")); + + verify(verificationService, never()).resetPassword(anyString(), anyString()); + } + + @Test + @WithMockUser + void processResetPassword_invalidToken_returnsFormViewWithError() throws Exception { + doThrow(new InvalidTokenException("This link has expired or was already used.")) + .when(verificationService).resetPassword("bad-token", "Secure1@"); + + mockMvc.perform(post("/reset-password") + .with(csrf()) + .param("token", "bad-token") + .param("password", "Secure1@") + .param("repeatPassword", "Secure1@")) + .andExpect(status().isOk()) + .andExpect(view().name("reset-password")) + .andExpect(model().attributeExists("errorMessage")); + } + + @Test + @WithMockUser + void processResetPassword_success_redirectsToSignIn() throws Exception { + doNothing().when(verificationService).resetPassword("raw-token", "Secure1@"); + + mockMvc.perform(post("/reset-password") + .with(csrf()) + .param("token", "raw-token") + .param("password", "Secure1@") + .param("repeatPassword", "Secure1@")) + .andExpect(status().is3xxRedirection()) + .andExpect(redirectedUrl("/sign-in")); + + verify(verificationService).resetPassword("raw-token", "Secure1@"); + } + +} diff --git a/src/test/java/com/weatherviewer/integration/UserRegistrationIntegrationTest.java b/src/test/java/com/weatherviewer/integration/UserRegistrationIntegrationTest.java index 91d4eb7..5f0eeec 100644 --- a/src/test/java/com/weatherviewer/integration/UserRegistrationIntegrationTest.java +++ b/src/test/java/com/weatherviewer/integration/UserRegistrationIntegrationTest.java @@ -113,7 +113,7 @@ void signUp_newUserHasDefaultRoleAndStatus() throws Exception { User savedUser = userRepository.findByEmail("defaults@example.com").orElseThrow(); assertThat(savedUser.getRole().name()).isEqualTo("USER"); - assertThat(savedUser.getStatus().name()).isEqualTo("ACTIVE"); + assertThat(savedUser.getStatus().name()).isEqualTo("PENDING"); } } diff --git a/src/test/java/com/weatherviewer/security/CustomAuthFailureHandlerTest.java b/src/test/java/com/weatherviewer/security/CustomAuthFailureHandlerTest.java new file mode 100644 index 0000000..60b8339 --- /dev/null +++ b/src/test/java/com/weatherviewer/security/CustomAuthFailureHandlerTest.java @@ -0,0 +1,84 @@ +package com.weatherviewer.security; + +import jakarta.servlet.ServletException; +import org.junit.jupiter.api.BeforeEach; +import org.junit.jupiter.api.Test; +import org.junit.jupiter.api.extension.ExtendWith; +import org.mockito.junit.jupiter.MockitoExtension; +import org.springframework.mock.web.MockHttpServletRequest; +import org.springframework.mock.web.MockHttpServletResponse; +import org.springframework.security.authentication.BadCredentialsException; +import org.springframework.security.authentication.DisabledException; +import org.springframework.security.core.AuthenticationException; + +import java.io.IOException; + +import static org.junit.jupiter.api.Assertions.assertEquals; + +@ExtendWith(MockitoExtension.class) +class CustomAuthFailureHandlerTest { + + private CustomAuthFailureHandler failureHandler; + private MockHttpServletRequest request; + private MockHttpServletResponse response; + + @BeforeEach + void setUp() { + failureHandler = new CustomAuthFailureHandler(); + request = new MockHttpServletRequest(); + response = new MockHttpServletResponse(); + } + + @Test + void onAuthenticationFailure_badCredentials_delegatesToDefaultUrlWithoutUnverifiedParam() + throws IOException, ServletException { + AuthenticationException exception = new BadCredentialsException("Invalid credentials"); + + failureHandler.onAuthenticationFailure(request, response, exception); + + assertEquals("/sign-in-failure", response.getRedirectedUrl()); + } + + @Test + void onAuthenticationFailure_disabledException_redirectsWithUnverifiedAndEmailParam() + throws IOException, ServletException { + request.setParameter("email", "john@example.com"); + AuthenticationException exception = new DisabledException("User account is disabled"); + + failureHandler.onAuthenticationFailure(request, response, exception); + + assertEquals("/sign-in-failure?unverified=true&email=john@example.com", response.getRedirectedUrl()); + } + + @Test + void onAuthenticationFailure_disabledException_withoutEmail_redirectsWithUnverifiedParamOnly() + throws IOException, ServletException { + AuthenticationException exception = new DisabledException("User account is disabled"); + + failureHandler.onAuthenticationFailure(request, response, exception); + + assertEquals("/sign-in-failure?unverified=true", response.getRedirectedUrl()); + } + + @Test + void onAuthenticationFailure_disabledException_withoutEmail_redirectsWithoutEmailParam() + throws IOException, ServletException { + AuthenticationException exception = new DisabledException("Account is disabled"); + + failureHandler.onAuthenticationFailure(request, response, exception); + + assertEquals("/sign-in-failure?unverified=true", response.getRedirectedUrl()); + } + + @Test + void onAuthenticationFailure_disabledException_withBlankEmail_redirectsWithoutEmailParam() + throws IOException, ServletException { + request.setParameter("email", " "); + AuthenticationException exception = new DisabledException("Account is disabled"); + + failureHandler.onAuthenticationFailure(request, response, exception); + + assertEquals("/sign-in-failure?unverified=true", response.getRedirectedUrl()); + } + +}