diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml
new file mode 100644
index 0000000..270625d
--- /dev/null
+++ b/.github/workflows/ci.yml
@@ -0,0 +1,116 @@
+name: CI
+
+on:
+ push:
+ branches: [ "main" ]
+ pull_request:
+ branches: [ "main", "dev" ]
+
+permissions:
+ contents: read
+
+jobs:
+
+ # ---------------------------------------------------------------------
+ # 1. Build + test on every push and every PR
+ # ---------------------------------------------------------------------
+ test:
+ name: Build & Test
+ runs-on: ubuntu-latest
+ steps:
+ - name: Checkout code
+ uses: actions/checkout@v7
+
+ - name: Set up JDK 17
+ uses: actions/setup-java@v5
+ with:
+ distribution: temurin
+ java-version: '17'
+ cache: maven
+
+ - name: Make mvnw executable
+ run: chmod +x ./mvnw
+
+ - name: Run tests
+ # Tests run against the in-memory H2 DB and spring.cache.type=simple,
+ # so no Postgres/Redis service containers are needed here.
+ # WEATHER_API_KEY just needs to be non-empty; tests mock the HTTP client.
+ env:
+ WEATHER_API_KEY: dummy-ci-key
+ run: ./mvnw -B test
+
+ - name: Publish test report
+ if: always()
+ uses: dorny/test-reporter@v3
+ with:
+ name: Maven Tests
+ path: target/surefire-reports/*.xml
+ reporter: java-junit
+
+ - name: Upload JaCoCo coverage report
+ if: always()
+ uses: actions/upload-artifact@v7
+ with:
+ name: jacoco-report
+ path: target/site/jacoco/
+
+ - name: Build jar
+ env:
+ WEATHER_API_KEY: dummy-ci-key
+ run: ./mvnw -B package -DskipTests
+
+ - name: Upload jar artifact
+ uses: actions/upload-artifact@v7
+ with:
+ name: app-jar
+ path: target/*.jar
+
+ # ---------------------------------------------------------------------
+ # 2. Build + push a versioned image to Docker Hub
+ # - runs ONLY on: pushes to main
+ # - every push to main -> :
A Spring Boot web app for tracking weather and forecasts across your favorite locations.
@@ -10,15 +10,19 @@
+
Overview · Features · Tech Stack · Getting Started · API Docs · + Observability · + Security · Testing · + CI/CD · Structure
@@ -26,37 +30,49 @@ ## Overview -WeatherViewer is a personal weather dashboard for tracking the places you care about. Sign up, search for any city, and save it — your dashboard then shows current conditions for every saved location at a glance, with hourly and daily forecasts just a click away. Mark your most-checked spots as favorites to keep them front and center. Live weather and location data come from the OpenWeatherMap API, and Redis caching keeps everything running smoothly behind the scenes. +WeatherViewer is a personal weather dashboard for tracking the places you care about. Sign up, verify your email, search for any city, and save it — your dashboard then shows current conditions for every saved location at a glance, with hourly and daily forecasts just a click away, in the units you prefer. Mark your most-checked spots as favorites to keep them front and center. Live weather and location data come from the OpenWeatherMap API, calls to which are cached and protected by retries and a circuit breaker, so a flaky upstream doesn't take your dashboard down with it. ## Features - **Authentication** — sign-up and sign-in with Spring Security, BCrypt password hashing, and role-based access (`USER` / `ADMIN`) -- **Location search** — look up cities by name via the OpenWeatherMap Geocoding API and save them to your account -- **Dashboard** — view current weather for all saved locations, sortable by date added, name, or favorite status +- **Email verification & password reset** — new accounts are confirmed via an emailed verification link, and a self-service "forgot password" flow lets users reset their password with an expiring token +- **Remember-me** — persistent-token "remember me" on sign-in, so sessions survive a browser restart +- **Account lockout** — an account is temporarily locked after repeated failed sign-in attempts, protecting it against brute-force guessing +- **Location search** — look up cities by name via the OpenWeatherMap Geocoding API and save them to your account, up to a configurable per-user limit +- **Dashboard** — view current weather for all saved locations, sortable by date added, name, or favorite status; if one location's weather fails to load, the rest of the dashboard still renders - **Favorites** — mark/unmark locations as favorites and filter the dashboard to show only those - **Forecasts** — hourly and daily forecast views for any saved location -- **User profile** — update account details from a dedicated profile page -- **REST API** — JSON endpoints for users, locations, and weather data, documented with OpenAPI/Swagger +- **Unit preference** — choose metric or imperial units on your profile; all displayed weather and forecast values are converted accordingly +- **Dark mode** — a light/dark theme toggle, remembered across pages +- **User profile** — update account details and preferences from a dedicated profile page +- **REST API** — JSON endpoints for users, locations, and weather data, documented with OpenAPI/Swagger, with pagination on the admin list endpoints - **Caching** — weather, forecast, and geocoding responses are cached (Redis in production, in-memory for local/dev) to reduce external API calls +- **Resilience** — retries with exponential backoff and a circuit breaker (Resilience4j) around every call to the OpenWeatherMap API +- **Rate limiting** — per-route request limits on sign-in, sign-up, and the REST API, safe against `X-Forwarded-For` spoofing and skipping static assets - **Database migrations** — schema is version-controlled and applied automatically via Liquibase +- **Observability** — Actuator health/liveness/readiness probes and Prometheus metrics exposed on a separate management port, plus request correlation IDs in logs +- **Hardened by default** — strict Content-Security-Policy, secure/`SameSite=Strict` session cookies, a single concurrent session per user, and a non-root container image ## Tech Stack - -| Layer | Technology | -|:-----------------|:-----------------------------------------------------------------------------------------| -| Language | Java 17 | -| Framework | Spring Boot 3.5 (Web, Security, Data JPA, Validation, Cache) | -| Templating | Thymeleaf | -| Database | PostgreSQL | -| Migrations | Liquibase | -| Caching | Redis (Spring Cache) | -| Mapping | MapStruct | -| API docs | springdoc-openapi (Swagger UI & Scalar) | -| Build tool | Maven | -| Testing | JUnit, Spring Boot Test, Spring Security Test, H2 (in-memory test DB), JaCoCo (coverage) | -| Containerization | Docker, Docker Compose | -| External API | [OpenWeatherMap](https://openweathermap.org/api) (current weather, forecast, geocoding) | +| Layer | Technology | +|:-----------------|:-------------------------------------------------------------------------------------------| +| Language | Java 17 | +| Framework | Spring Boot 3.5 (Web, Security, Data JPA, Validation, Cache, Mail, Actuator) | +| Templating | Thymeleaf | +| Database | PostgreSQL | +| Migrations | Liquibase | +| Caching | Redis (Spring Cache) | +| Resilience | Resilience4j (retry + circuit breaker) | +| Mapping | MapStruct | +| Mail | Spring Mail (SMTP) — email verification and password reset | +| API docs | springdoc-openapi (Swagger UI & Scalar) | +| Observability | Spring Boot Actuator (health/liveness/readiness), Micrometer + Prometheus, correlation IDs | +| Build tool | Maven | +| Testing | JUnit, Spring Boot Test, Spring Security Test, H2 (in-memory test DB), JaCoCo (coverage) | +| Containerization | Docker, Docker Compose (non-root runtime image) | +| CI/CD | GitHub Actions (build/test, coverage, Docker Hub image push) | +| External API | [OpenWeatherMap](https://openweathermap.org/api) (current weather, forecast, geocoding) | ## Prerequisites @@ -64,6 +80,7 @@ WeatherViewer is a personal weather dashboard for tracking the places you care a - Maven - Docker and Docker Compose (recommended — handles Postgres and Redis for you) - An [OpenWeatherMap API key](https://openweathermap.org/api) (free tier is sufficient) +- An SMTP server/account for sending verification and password-reset emails (a local dev tool like [MailHog](https://github.com/mailhog/MailHog) or [Mailpit](https://github.com/axllent/mailpit) works well for local development) ## Getting Started @@ -92,10 +109,36 @@ WEATHER_API_KEY=your_openweathermap_api_key SPRING_DATA_REDIS_HOST=localhost SPRING_DATA_REDIS_PORT=6379 SPRING_CACHE_TYPE=redis + +# Remember-me (any long random string) +REMEMBER_ME_KEY=change-me-to-a-random-secret + +# Mail — used for account verification and password-reset emails +MAIL_HOST=localhost +MAIL_PORT=1025 +MAIL_USERNAME= +MAIL_PASSWORD= +MAIL_SMTP_AUTH=false +MAIL_SMTP_STARTTLS=false +MAIL_FROM=no-reply@weatherviewer.local + +# Base URL used to build links inside verification/reset emails +APP_BASE_URL=http://localhost:8080 ``` > **Never commit your real `.env` file or API key.** Add `.env` to `.gitignore` and rotate any key that has previously been pushed to a public repo. +Other useful settings (all have sensible defaults — see `application.properties`): + +| Variable | Default | Purpose | +|:-----------------------------------|:----------|:-------------------------------------------------------------| +| `LOCATION_MAX_PER_USER` | `100` | Max saved locations per user | +| `ACCOUNT_LOCKOUT_MAX_ATTEMPTS` | `5` | Failed sign-ins before an account is locked | +| `ACCOUNT_LOCKOUT_DURATION_MINUTES` | `15` | How long a locked account stays locked | +| `RATE_LIMIT_ENABLED` | `true` | Toggle the rate-limiting filter | +| `TRUSTED_PROXIES` | *(empty)* | CIDRs/IPs allowed to set `X-Forwarded-For` for rate limiting | +| `MANAGEMENT_SERVER_PORT` | `8081` | Port for Actuator health/metrics endpoints | + ### 3. Start dependencies (Postgres + Redis) For local development: @@ -111,7 +154,7 @@ This starts Postgres on port `5433` and Redis on port `6379`, matching the `.env Using the Maven wrapper: ```bash -mvn spring-boot:run +./mvnw spring-boot:run ``` The app will be available at **http://localhost:8080**. @@ -124,7 +167,7 @@ To run the full stack (app + Postgres + Redis) in containers: docker compose up -d ``` -This pulls the app image, applies Liquibase migrations on startup, and wires up all three services with health checks. +This pulls the app image, applies Liquibase migrations on startup, and wires up all three services with health checks. The app container runs as a non-root user and exposes both the app port (`8080`) and the management port (`8081`). ## API Documentation @@ -136,38 +179,70 @@ The project provides interactive API documentation using both OpenAPI (Swagger U | **Swagger UI** | `/swagger-ui/index.html` | Classic Swagger interface to explore and test endpoints. | | **OpenAPI Spec** | `/v3/api-docs` | Raw OpenAPI 3.0 specification in JSON format. | -REST endpoints are namespaced under `/api/v1/` (e.g. `/api/v1/weather/city`, `/api/v1/locations/my`, `/api/v1/users`). +REST endpoints are namespaced under `/api/v1/` (e.g. `/api/v1/weather/city`, `/api/v1/locations/my`, `/api/v1/users`). The admin-facing list endpoints (`GET /api/v1/users`, `GET /api/v1/locations`) are paginated, with a maximum page size of 100. + +## Observability + +Actuator runs on a separate management port so it can be kept off the public network: + +| Endpoint | Purpose | +|:--------------------------------------------|:-------------------------------------------------| +| `http://localhost:8081/actuator/health` | Liveness/readiness health check (used by Docker) | +| `http://localhost:8081/actuator/prometheus` | Prometheus-formatted metrics | +| `http://localhost:8081/actuator/info` | Build/app info | + +Every log line is tagged with a request correlation ID, and HTTP request latency is exported as a histogram for easy percentile/SLO tracking. + +## Security + +- Passwords are hashed with BCrypt; sign-in is protected by per-account lockout after repeated failed attempts +- Session cookies are `Secure` and `SameSite=Strict`, and each user is limited to one concurrent session +- A strict Content-Security-Policy header is set on every response +- Rate limiting on sign-in, sign-up, and the REST API is resistant to `X-Forwarded-For` spoofing (only trusted proxies listed in `TRUSTED_PROXIES` are honored) and skips static assets +- The OpenWeatherMap API key is never written to logs +- The production container image runs as a non-root user ## Running Tests ```bash -mvn test +./mvnw test ``` -Tests run against an in-memory H2 database, so no external services are required. The suite includes unit tests, MVC/REST controller tests, repository tests, and full integration tests for auth, search, profile, and weather flows. JaCoCo generates a coverage report at `target/site/jacoco/index.html` after running tests. +Tests run against an in-memory H2 database, so no external services are required. The suite includes unit tests, MVC/REST controller tests, repository tests, and full integration tests for auth (including verification, password reset, and remember-me), search, profile, and weather flows. JaCoCo generates a coverage report at `target/site/jacoco/index.html` after running tests. + +## CI/CD + +Every push to `main` and every pull request into `main`/`dev` runs through GitHub Actions: + +1. **Build & Test** — compiles the project and runs the full test suite against H2, publishing a JUnit test report and a JaCoCo coverage report as workflow artifacts. +2. **Docker build & push** — on pushes to `main`, builds the application image and pushes it to Docker Hub as `podllev/weather-viewer`. + +See `.github/workflows/ci.yml` for the full pipeline. ## Project Structure ``` src/main/java/com/weatherviewer/ -├── config/ # Security and app-level configuration -├── controller/ # Thymeleaf (MVC) controllers — sign-in/up, home, search, profile, forecast -├── rest/ # REST API controllers (/api/v1/...) -├── dto/ # Data transfer objects -├── model/ # JPA entities (User, Location) and enums -├── repository/ # Spring Data JPA repositories -├── service/ # Business logic interfaces + implementations -│ └── integration/ # OpenWeatherMap API client and caching layer -├── security/ # Spring Security principal, success/failure handlers -├── validation/ # Custom annotations and validators (lat/lon, password, uniqueness) -├── mapper/ # MapStruct entity↔DTO mappers -└── exception/ # Custom exceptions and global exception handlers +├── config/ # Security and app-level configuration +├── controller/ # Thymeleaf (MVC) controllers — sign-in/up, home, search, profile, forecast, password reset +├── rest/ # REST API controllers (/api/v1/...) +├── dto/ # Data transfer objects +├── model/ # JPA entities (User, Location, VerificationToken) and enums +├── ratelimit/ # Rate-limiting filter and Redis-backed fixed-window limiter +├── repository/ # Spring Data JPA repositories +├── service/ # Business logic interfaces + implementations (mail, verification, weather, users, locations) +│ └── integration/ # OpenWeatherMap API client, retry/circuit-breaker config, and caching layer +├── security/ # Spring Security principal, success/failure/lockout handlers +├── validation/ # Custom annotations and validators (lat/lon, password, uniqueness, location limit) +├── mapper/ # MapStruct entity↔DTO mappers +└── exception/ # Custom exceptions and global exception handlers src/main/resources/ -├── templates/ # Thymeleaf views -├── static/ # CSS, JS, images -└── liquibase/ # Database changelogs +├── templates/ # Thymeleaf views +├── static/ # CSS, JS, images +└── liquibase/ # Database changelogs ``` + ## License - + MIT — see [LICENSE](LICENSE) for details. diff --git a/docker-compose.yml b/docker-compose.yml index 16c2f25..0d395dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -1,17 +1,21 @@ services: weather_viewer: + build: + context: . + dockerfile: Dockerfile image: podllev/weather-viewer:latest container_name: weather_viewer restart: always ports: - "8080:8080" + - "8081:8081" env_file: - .env environment: SPRING_DATASOURCE_URL: jdbc:postgresql://postgres:5432/weather SPRING_DATA_REDIS_HOST: redis healthcheck: - test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/actuator/health" ] + test: [ "CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8081/actuator/health" ] interval: 15s timeout: 5s retries: 3 diff --git a/pom.xml b/pom.xml index 7437962..2928ac7 100644 --- a/pom.xml +++ b/pom.xml @@ -10,9 +10,9 @@+ * Deliberately separate from the JVM-wide common {@code ForkJoinPool} + * (what a bare {@code parallelStream()} would use): that pool is shared + * with unrelated parallel streams elsewhere in the JVM and has no + * request-scoped bound, so a user with many saved locations could + * starve it for everyone. Sized via {@code weather.dashboard.fetch-pool-size} + * (default 20) and shut down automatically on context close. + */ + @Bean(destroyMethod = "shutdown") + public ExecutorService weatherFetchExecutor( + @Value("${weather.dashboard.fetch-pool-size:20}") int poolSize) { + return Executors.newFixedThreadPool(poolSize, new CustomizableThreadFactory("weather-fetch-")); + } + + /** + * Dedicated, bounded thread pool used by + * {@link com.weatherviewer.service.impl.MailEventListener} to send + * verification/password-reset emails off the request thread. + *
+ * Kept separate from {@link #weatherFetchExecutor} for the same reason + * that one is kept separate from the JVM-wide common pool: a burst of + * sign-ups or reset requests (each possibly retried a few times against + * a slow SMTP server) shouldn't be able to starve weather-dashboard + * fetches, or vice versa. Sized via {@code mail.async.pool-size} + * (default 5) and shut down automatically on context close. + */ + @Bean(name = "mailTaskExecutor", destroyMethod = "shutdown") + public ExecutorService mailTaskExecutor( + @Value("${mail.async.pool-size:5}") int poolSize) { + return Executors.newFixedThreadPool(poolSize, new CustomizableThreadFactory("mail-send-")); + } + } diff --git a/src/main/java/com/weatherviewer/config/SecurityConfig.java b/src/main/java/com/weatherviewer/config/SecurityConfig.java index bb8d0e9..39530b6 100644 --- a/src/main/java/com/weatherviewer/config/SecurityConfig.java +++ b/src/main/java/com/weatherviewer/config/SecurityConfig.java @@ -4,6 +4,7 @@ import com.weatherviewer.security.CustomAuthFailureHandler; import com.weatherviewer.security.CustomAuthSuccessHandler; import lombok.RequiredArgsConstructor; +import org.springframework.beans.factory.annotation.Value; import org.springframework.context.annotation.Bean; import org.springframework.context.annotation.Configuration; import org.springframework.security.authentication.AuthenticationManager; @@ -11,12 +12,20 @@ import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; import org.springframework.security.config.annotation.web.builders.HttpSecurity; import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity; +import org.springframework.security.core.session.SessionRegistry; +import org.springframework.security.core.session.SessionRegistryImpl; +import org.springframework.security.core.userdetails.UserDetailsService; import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder; import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.security.web.SecurityFilterChain; import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; +import org.springframework.security.web.authentication.rememberme.JdbcTokenRepositoryImpl; +import org.springframework.security.web.authentication.rememberme.PersistentTokenBasedRememberMeServices; +import org.springframework.security.web.authentication.rememberme.PersistentTokenRepository; import org.springframework.security.web.csrf.CookieCsrfTokenRepository; +import javax.sql.DataSource; + /** * Core Spring Security configuration. *
@@ -36,6 +45,21 @@ public class SecurityConfig {
private final CustomAuthSuccessHandler customAuthSuccessHandler;
private final CustomAuthFailureHandler customAuthFailureHandler;
private final RateLimitingFilter rateLimitingFilter;
+ private final DataSource dataSource;
+ private final UserDetailsService userDetailsService;
+
+ /**
+ * Secret used to sign remember-me tokens. Must be a long, random,
+ * environment-specific value (e.g. {@code openssl rand -hex 32}) —
+ * never left at a default in production, since anyone who knows it
+ * could forge a valid remember-me cookie.
+ */
+ @Value("${security.remember-me.key}")
+ private String rememberMeKey;
+
+ /** How long a remember-me cookie stays valid before the user must sign in again. Defaults to 14 days. */
+ @Value("${security.remember-me.token-validity-seconds:1209600}")
+ private int rememberMeTokenValiditySeconds;
/**
* Defines the security filter chain: public routes (sign-in/up, static
@@ -53,6 +77,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()
@@ -77,14 +103,50 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti
.logoutUrl("/sign-out")
.invalidateHttpSession(true)
.clearAuthentication(true)
- .deleteCookies("JSESSIONID")
+ .deleteCookies("JSESSIONID", "remember-me")
+ .addLogoutHandler(rememberMeServices())
.logoutSuccessUrl("/sign-in")
)
+ .sessionManagement(session -> session
+ .maximumSessions(1)
+ .maxSessionsPreventsLogin(false)
+ .sessionRegistry(sessionRegistry())
+ .expiredUrl("/sign-in?expired")
+ )
+ .rememberMe(rememberMe -> rememberMe
+ .rememberMeServices(rememberMeServices())
+ .key(rememberMeKey)
+ )
+ .headers(headers -> headers
+ .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 https://use.fontawesome.com https://cdnjs.cloudflare.com; " +
+ "connect-src 'self' https://cdn.jsdelivr.net; " +
+ "img-src 'self' data: https:;"
+ )
+ )
+ )
.addFilterAfter(rateLimitingFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
+ /**
+ * Tracks each principal's active sessions so
+ * {@link org.springframework.security.web.authentication.session.ConcurrentSessionControlAuthenticationStrategy}
+ * can enforce {@code maximumSessions(1)}. Must be a singleton bean (not
+ * created inline) so the same instance is shared between the security
+ * filter chain and HttpSessionEventPublisher, which needs it to
+ * remove sessions from the registry when they're invalidated or expire.
+ */
+ @Bean
+ public SessionRegistry sessionRegistry() {
+ return new SessionRegistryImpl();
+ }
+
/** Exposes Spring Security's default {@link AuthenticationManager}, used by {@link com.weatherviewer.service.LoginService}. */
@Bean
public AuthenticationManager authenticationManager(AuthenticationConfiguration config) throws Exception {
@@ -97,4 +159,37 @@ public PasswordEncoder passwordEncoder() {
return new BCryptPasswordEncoder(12);
}
+ /**
+ * Backs remember-me tokens with the {@code persistent_logins} table
+ * (schema defined in the {@code 06-add-persistent-logins.xml} Liquibase
+ * changeset) instead of Spring Security's simpler hash-based cookie.
+ * This lets a stolen/leaked cookie be invalidated server-side and
+ * rotates the token on every use, so a captured cookie is only valid
+ * for a single re-use before it's detected and the whole series is
+ * revoked.
+ */
+ @Bean
+ public PersistentTokenRepository persistentTokenRepository() {
+ JdbcTokenRepositoryImpl repository = new JdbcTokenRepositoryImpl();
+ repository.setDataSource(dataSource);
+ return repository;
+ }
+
+ /**
+ * Issues and validates the {@code remember-me} cookie. Token validity
+ * is configurable via {@code security.remember-me.token-validity-seconds}
+ * (default 14 days); the signing key comes from
+ * {@code security.remember-me.key} and must be set per-environment.
+ */
+ @Bean
+ public PersistentTokenBasedRememberMeServices rememberMeServices() {
+ PersistentTokenBasedRememberMeServices services =
+ new PersistentTokenBasedRememberMeServices(rememberMeKey, userDetailsService, persistentTokenRepository());
+ services.setTokenValiditySeconds(rememberMeTokenValiditySeconds);
+ services.setParameter("remember-me");
+ services.setCookieName("remember-me");
+ services.setUseSecureCookie(true);
+ return services;
+ }
+
}
diff --git a/src/main/java/com/weatherviewer/config/WebMvcConfig.java b/src/main/java/com/weatherviewer/config/WebMvcConfig.java
new file mode 100644
index 0000000..7b9af5a
--- /dev/null
+++ b/src/main/java/com/weatherviewer/config/WebMvcConfig.java
@@ -0,0 +1,30 @@
+package com.weatherviewer.config;
+
+import org.springframework.context.annotation.Bean;
+import org.springframework.context.annotation.Configuration;
+import org.springframework.web.servlet.config.annotation.InterceptorRegistry;
+import org.springframework.web.servlet.config.annotation.WebMvcConfigurer;
+import org.springframework.web.servlet.i18n.LocaleChangeInterceptor;
+
+/**
+ * Lets any request switch the active locale by adding a {@code lang} query
+ * parameter (e.g. {@code ?lang=uk}). The new locale is then persisted on
+ * the session by {@link AppConfig#localeResolver()}, so it applies to
+ * subsequent requests too, not just the one carrying the parameter.
+ */
+@Configuration
+public class WebMvcConfig implements WebMvcConfigurer {
+
+ @Bean
+ public LocaleChangeInterceptor localeChangeInterceptor() {
+ LocaleChangeInterceptor interceptor = new LocaleChangeInterceptor();
+ interceptor.setParamName("lang");
+ return interceptor;
+ }
+
+ @Override
+ public void addInterceptors(InterceptorRegistry registry) {
+ registry.addInterceptor(localeChangeInterceptor());
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/controller/AuthController.java b/src/main/java/com/weatherviewer/controller/AuthController.java
index 25619b4..6d59f52 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,25 @@ 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) Boolean locked,
+ @RequestParam(required = false) String email,
+ RedirectAttributes redirectAttributes) {
+ log.info("Sign-in failed, unverified={}, locked={}", unverified, locked);
+
+ String emailParam = email != null && !email.isBlank() ? "&email=" + email : "";
+ if (Boolean.TRUE.equals(unverified)) {
+ redirectAttributes.addFlashAttribute("errorMessage",
+ "Please verify your email before signing in.");
+ return "redirect:/sign-in?unverified=true" + emailParam;
+ }
+
+ if (Boolean.TRUE.equals(locked)) {
+ redirectAttributes.addFlashAttribute("errorMessage",
+ "Too many failed sign-in attempts. Your account is temporarily locked — please try again in a few minutes.");
+ return "redirect:/sign-in?locked=true" + emailParam;
+ }
+
redirectAttributes.addFlashAttribute("errorMessage", "Invalid email or password. Please try again.");
return "redirect:/sign-in";
}
@@ -76,14 +99,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 +120,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/ForecastController.java b/src/main/java/com/weatherviewer/controller/ForecastController.java
index eeb7f6f..c8cae59 100644
--- a/src/main/java/com/weatherviewer/controller/ForecastController.java
+++ b/src/main/java/com/weatherviewer/controller/ForecastController.java
@@ -4,6 +4,7 @@
import com.weatherviewer.security.SecUser;
import com.weatherviewer.service.LocationService;
import com.weatherviewer.service.WeatherApiService;
+import com.weatherviewer.service.helper.UnitConverter;
import com.weatherviewer.validation.annotation.Latitude;
import com.weatherviewer.validation.annotation.Longitude;
import lombok.RequiredArgsConstructor;
@@ -27,6 +28,7 @@ public class ForecastController {
private final WeatherApiService weatherApiService;
private final LocationService locationService;
+ private final UnitConverter unitConverter;
/**
* Renders the forecast page for the saved location at the given
@@ -42,8 +44,10 @@ public String getForecast(@RequestParam("lat") @Latitude double latitude,
log.info("Fetching forecast for user={} at lat={}, lon={}", user.getUsername(), latitude, longitude);
String locationName = locationService.getByCoordinatesAndUserId(latitude, longitude, user.getId()).getName();
- List
+ * Each fetch is capped at {@code weather.dashboard.fetch-timeout-ms}
+ * and failures are isolated per location: a single slow or failing
+ * provider call surfaces that one location as unavailable instead of
+ * failing the whole dashboard.
*/
@GetMapping("/")
public String home(Model model, @AuthenticationPrincipal SecUser user,
- @RequestParam(required = false, defaultValue = "date") String sort) {
- log.info("Home page requested by user '{}', sort={}", user.getUsername(), sort);
-
- List
+ * 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/controller/ProfileController.java b/src/main/java/com/weatherviewer/controller/ProfileController.java
index 8b7a4d0..8767ca0 100644
--- a/src/main/java/com/weatherviewer/controller/ProfileController.java
+++ b/src/main/java/com/weatherviewer/controller/ProfileController.java
@@ -4,10 +4,18 @@
import com.weatherviewer.model.User;
import com.weatherviewer.security.SecUser;
import com.weatherviewer.service.UserService;
+import jakarta.servlet.http.HttpServletRequest;
+import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
+import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
+import org.springframework.security.core.Authentication;
import org.springframework.security.core.annotation.AuthenticationPrincipal;
+import org.springframework.security.core.context.SecurityContext;
+import org.springframework.security.core.context.SecurityContextHolder;
+import org.springframework.security.web.context.HttpSessionSecurityContextRepository;
+import org.springframework.security.web.context.SecurityContextRepository;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.validation.BindingResult;
@@ -16,6 +24,8 @@
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.servlet.mvc.support.RedirectAttributes;
+import java.util.UUID;
+
/**
* Thymeleaf controller for the current user's account profile page:
* displaying stored profile fields and applying edits to them.
@@ -27,6 +37,20 @@ public class ProfileController {
private final UserService userService;
+ /**
+ * Spring Security 6 defaults to "explicit save" mode: {@code SecurityContextHolderFilter}
+ * loads the session's context at the start of a request but no longer writes
+ * {@link SecurityContextHolder}'s context back to the session automatically at
+ * the end of one. Mutating {@link SecurityContextHolder} alone (as earlier
+ * versions of this code did) therefore only lasted for the remainder of the
+ * current request — the next request would reload the untouched, stale
+ * session copy. This repository lets {@link #refreshAuthenticatedPrincipal}
+ * write the refreshed context back into the session explicitly, the same
+ * place {@link com.weatherviewer.security.UserDetailsServiceImpl}-backed
+ * login already stores it, so it's actually still there on the next request.
+ */
+ private final SecurityContextRepository securityContextRepository = new HttpSessionSecurityContextRepository();
+
/** Renders the profile page, pre-populated with the signed-in user's current details. */
@GetMapping("/profile")
public String getProfile(@AuthenticationPrincipal SecUser user, Model model) {
@@ -48,6 +72,7 @@ public String getProfile(@AuthenticationPrincipal SecUser user, Model model) {
public String updateProfile(@Valid @ModelAttribute("user") UpdateUserDto dto,
BindingResult bindingResult,
@AuthenticationPrincipal SecUser user,
+ HttpServletRequest request, HttpServletResponse response,
RedirectAttributes redirectAttributes, Model model) {
if (bindingResult.hasErrors()) {
User userEntity = userService.getEntityById(user.getId());
@@ -56,8 +81,38 @@ public String updateProfile(@Valid @ModelAttribute("user") UpdateUserDto dto,
}
userService.update(user.getId(), dto);
+ refreshAuthenticatedPrincipal(user.getId(), request, response);
redirectAttributes.addFlashAttribute("successMessage", "Profile updated successfully!");
return "redirect:/profile";
}
+ /**
+ * Replaces the session's authenticated principal with a fresh {@link SecUser}
+ * built from the just-saved entity.
+ *
+ * {@code @AuthenticationPrincipal SecUser} is the same immutable object that
+ * was built once at sign-in and stored in the session's {@code SecurityContext}
+ * ({@link com.weatherviewer.security.UserDetailsServiceImpl}). Saving profile
+ * changes to the database does not, by itself, update that cached principal -
+ * every other controller reading {@code user.getUnits()} (or full name, email,
+ * etc.) off {@code @AuthenticationPrincipal} would keep seeing the pre-update
+ * values for the rest of the session, e.g. the dashboard silently continuing
+ * to render in the old unit system after switching Celsius/Fahrenheit until
+ * the user logs out and back in. Rebuilding and re-setting the principal here
+ * makes the change visible immediately, on the very next request.
+ */
+ private void refreshAuthenticatedPrincipal(UUID userId, HttpServletRequest request, HttpServletResponse response) {
+ User updatedEntity = userService.getEntityById(userId);
+ SecUser refreshedPrincipal = SecUser.fromUser(updatedEntity);
+
+ Authentication currentAuth = SecurityContextHolder.getContext().getAuthentication();
+ Authentication refreshedAuth = new UsernamePasswordAuthenticationToken(
+ refreshedPrincipal, currentAuth.getCredentials(), refreshedPrincipal.getAuthorities());
+
+ SecurityContext context = SecurityContextHolder.createEmptyContext();
+ context.setAuthentication(refreshedAuth);
+ SecurityContextHolder.setContext(context);
+ securityContextRepository.saveContext(context, request, response);
+ }
+
}
diff --git a/src/main/java/com/weatherviewer/dto/AddLocationDto.java b/src/main/java/com/weatherviewer/dto/AddLocationDto.java
index 439e209..665afb5 100644
--- a/src/main/java/com/weatherviewer/dto/AddLocationDto.java
+++ b/src/main/java/com/weatherviewer/dto/AddLocationDto.java
@@ -1,6 +1,7 @@
package com.weatherviewer.dto;
import com.weatherviewer.validation.annotation.Latitude;
+import com.weatherviewer.validation.annotation.LocationLimit;
import com.weatherviewer.validation.annotation.Longitude;
import com.weatherviewer.validation.annotation.UniqueLocation;
import io.swagger.v3.oas.annotations.media.Schema;
@@ -19,6 +20,7 @@
@ToString
@Accessors(chain = true)
@UniqueLocation
+@LocationLimit
@Schema(description = "Payload for creating a saved location")
public class AddLocationDto {
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/dto/UpdateUserDto.java b/src/main/java/com/weatherviewer/dto/UpdateUserDto.java
index 412a539..789c529 100644
--- a/src/main/java/com/weatherviewer/dto/UpdateUserDto.java
+++ b/src/main/java/com/weatherviewer/dto/UpdateUserDto.java
@@ -39,4 +39,7 @@ public class UpdateUserDto {
@ToString.Exclude
private String password;
+ @Schema(description = "Preferred unit system for displaying weather (\"METRIC\" or \"IMPERIAL\"). Omit to keep the current preference.", example = "IMPERIAL")
+ private String units;
+
}
diff --git a/src/main/java/com/weatherviewer/dto/UserDto.java b/src/main/java/com/weatherviewer/dto/UserDto.java
index a0efb56..f826587 100644
--- a/src/main/java/com/weatherviewer/dto/UserDto.java
+++ b/src/main/java/com/weatherviewer/dto/UserDto.java
@@ -1,6 +1,7 @@
package com.weatherviewer.dto;
import com.weatherviewer.model.enums.Role;
+import com.weatherviewer.model.enums.UnitSystem;
import com.weatherviewer.model.enums.UserStatus;
import io.swagger.v3.oas.annotations.media.Schema;
import jakarta.validation.constraints.Email;
@@ -46,6 +47,10 @@ public class UserDto {
@NotNull(message = "Role cannot be null")
private Role role;
+ @Schema(description = "Preferred unit system for displaying weather")
+ @NotNull(message = "Units cannot be null")
+ private UnitSystem units;
+
@Schema(description = "Locations saved by this user")
@NotNull(message = "Locations list cannot be null")
private List
+ * 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/model/enums/UnitSystem.java b/src/main/java/com/weatherviewer/model/enums/UnitSystem.java
new file mode 100644
index 0000000..aeb8587
--- /dev/null
+++ b/src/main/java/com/weatherviewer/model/enums/UnitSystem.java
@@ -0,0 +1,36 @@
+package com.weatherviewer.model.enums;
+
+import com.weatherviewer.exception.notfound.UnitSystemNotFoundException;
+
+/**
+ * A user's preferred unit system for displaying weather data.
+ *
+ * Weather is always fetched from the provider and cached in metric units
+ * (see {@code WeatherApiClient}); conversion to the user's preferred
+ * system happens at render time via {@code UnitConverter}, so switching a
+ * user's preference never invalidates the shared weather cache.
+ */
+public enum UnitSystem {
+
+ /** Celsius, meters/second. */
+ METRIC,
+ /** Fahrenheit, miles/hour. */
+ IMPERIAL;
+
+ /**
+ * Resolves a unit system by name, case-insensitively.
+ *
+ * @param name the unit system name to look up (e.g. {@code "imperial"})
+ * @return the matching {@link UnitSystem}
+ * @throws UnitSystemNotFoundException if no unit system matches the given name
+ */
+ public static UnitSystem getInstance(String name) {
+ for (UnitSystem unitSystem : values()) {
+ if (unitSystem.name().equalsIgnoreCase(name)) {
+ return unitSystem;
+ }
+ }
+ throw new UnitSystemNotFoundException("Unit system not found by name: " + name);
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/ratelimit/RateLimitProperties.java b/src/main/java/com/weatherviewer/ratelimit/RateLimitProperties.java
index acc22b0..33b195c 100644
--- a/src/main/java/com/weatherviewer/ratelimit/RateLimitProperties.java
+++ b/src/main/java/com/weatherviewer/ratelimit/RateLimitProperties.java
@@ -31,6 +31,13 @@ public class RateLimitProperties {
/** Path-prefix-specific overrides, checked longest-prefix-first. */
private List
+ * Listens to the {@link AuthenticationFailureBadCredentialsEvent} and
+ * {@link AuthenticationSuccessEvent} that Spring Security's default
+ * {@code AuthenticationEventPublisher} raises around every
+ * {@code DaoAuthenticationProvider} attempt. Once an account is locked,
+ * {@link SecUser#isAccountNonLocked()} makes subsequent attempts fail with
+ * {@code LockedException} instead of {@code BadCredentialsException} — so
+ * this listener naturally stops incrementing further while the lock is in
+ * effect, rather than perpetually extending it.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class AccountLockoutListener {
+
+ private final UserRepository userRepository;
+
+ /** Consecutive failures before an account is locked. */
+ @Value("${security.account-lockout.max-attempts:5}")
+ private int maxAttempts;
+
+ /** How long an account stays locked once the threshold is hit. */
+ @Value("${security.account-lockout.lock-duration-minutes:15}")
+ private long lockDurationMinutes;
+
+ /**
+ * On a failed login with valid credentials-format-but-wrong-password
+ * (or, thanks to {@code hideUserNotFoundExceptions}, an unknown email
+ * too — though in that case there's no matching row to update),
+ * increments the account's failure counter and locks it once
+ * {@link #maxAttempts} is reached.
+ */
+ @EventListener
+ @Transactional
+ public void onAuthenticationFailure(AuthenticationFailureBadCredentialsEvent event) {
+ String email = event.getAuthentication().getName();
+ userRepository.findByEmail(email).ifPresent(user -> {
+ int attempts = user.getFailedLoginAttempts() + 1;
+ user.setFailedLoginAttempts(attempts);
+
+ if (attempts >= maxAttempts) {
+ user.setLockedUntil(LocalDateTime.now().plusMinutes(lockDurationMinutes));
+ log.warn("Account locked for {} minutes after {} consecutive failed sign-in attempts: {}",
+ lockDurationMinutes, attempts, email);
+ } else {
+ log.debug("Failed sign-in attempt {}/{} for {}", attempts, maxAttempts, email);
+ }
+
+ userRepository.save(user);
+ });
+ }
+
+ /**
+ * Clears the failure counter and any active lockout on a successful
+ * sign-in, so a legitimate user who mistyped their password a few
+ * times isn't left partway toward a lockout on their next visit.
+ */
+ @EventListener
+ @Transactional
+ public void onAuthenticationSuccess(AuthenticationSuccessEvent event) {
+ if (!(event.getAuthentication().getPrincipal() instanceof SecUser secUser)) {
+ return;
+ }
+
+ userRepository.findById(secUser.getId()).ifPresent(user -> {
+ if (user.getFailedLoginAttempts() != 0 || user.getLockedUntil() != null) {
+ user.setFailedLoginAttempts(0);
+ user.setLockedUntil(null);
+ userRepository.save(user);
+ }
+ });
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java b/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java
index 73c9d8f..7d66e64 100644
--- a/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java
+++ b/src/main/java/com/weatherviewer/security/CustomAuthFailureHandler.java
@@ -1,12 +1,34 @@
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.authentication.LockedException;
+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".
+ *
+ * A {@link LockedException} means {@link com.weatherviewer.security.SecUser#isAccountNonLocked()}
+ * is {@code false} — {@link com.weatherviewer.security.AccountLockoutListener}
+ * locked the account after too many recent failed attempts. That's
+ * distinguished via a {@code locked} query parameter, so the sign-in page
+ * can tell the user to wait rather than implying their password is wrong.
*/
@Component
public class CustomAuthFailureHandler extends SimpleUrlAuthenticationFailureHandler {
@@ -15,4 +37,33 @@ public CustomAuthFailureHandler() {
super("/sign-in-failure");
}
+ @Override
+ public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
+ AuthenticationException exception) throws IOException, ServletException {
+ if (exception instanceof LockedException) {
+ redirectWithParam(request, response, "locked");
+ return;
+ }
+
+ if (!(exception instanceof DisabledException)) {
+ super.onAuthenticationFailure(request, response, exception);
+ return;
+ }
+
+ redirectWithParam(request, response, "unverified");
+ }
+
+ private void redirectWithParam(HttpServletRequest request, HttpServletResponse response, String param)
+ throws IOException {
+ UriComponentsBuilder targetUrl = UriComponentsBuilder.fromPath("/sign-in-failure")
+ .queryParam(param, "true");
+
+ String email = request.getParameter("email");
+ if (email != null && !email.isBlank()) {
+ targetUrl.queryParam("email", email);
+ }
+
+ getRedirectStrategy().sendRedirect(request, response, targetUrl.toUriString());
+ }
+
}
diff --git a/src/main/java/com/weatherviewer/security/SecUser.java b/src/main/java/com/weatherviewer/security/SecUser.java
index 8f723b6..7520886 100644
--- a/src/main/java/com/weatherviewer/security/SecUser.java
+++ b/src/main/java/com/weatherviewer/security/SecUser.java
@@ -1,6 +1,7 @@
package com.weatherviewer.security;
import com.weatherviewer.model.User;
+import com.weatherviewer.model.enums.UnitSystem;
import com.weatherviewer.model.enums.UserStatus;
import lombok.Getter;
import lombok.RequiredArgsConstructor;
@@ -8,6 +9,7 @@
import org.springframework.security.core.authority.SimpleGrantedAuthority;
import org.springframework.security.core.userdetails.UserDetails;
+import java.time.LocalDateTime;
import java.util.Collection;
import java.util.Set;
import java.util.UUID;
@@ -16,11 +18,14 @@
* Spring Security {@link UserDetails} adapter around this application's
* {@link User} entity.
*
- * All four account-state checks ({@code isAccountNonExpired},
- * {@code isAccountNonLocked}, {@code isCredentialsNonExpired},
- * {@code isEnabled}) are backed by the single {@link #isActive} flag —
+ * {@code isAccountNonExpired}, {@code isCredentialsNonExpired}, and
+ * {@code isEnabled} are all backed by the single {@link #isActive} flag —
* this app doesn't distinguish between those states, so any non-{@code ACTIVE}
* {@link UserStatus} simply locks the account out of authentication.
+ * {@code isAccountNonLocked} is separate: it reflects the temporary,
+ * self-clearing lockout applied after repeated failed sign-in attempts
+ * (see {@link com.weatherviewer.security.AccountLockoutListener}), not the
+ * account's persistent {@link UserStatus}.
*/
@Getter
@RequiredArgsConstructor
@@ -32,6 +37,8 @@ public class SecUser implements UserDetails {
private final Set
+ * In practice these methods are only ever invoked by
+ * {@link com.weatherviewer.service.impl.MailEventListener}, asynchronously
+ * and after the transaction that created the underlying token has
+ * committed (see {@link com.weatherviewer.event.VerificationEmailRequestedEvent}
+ * / {@link com.weatherviewer.event.PasswordResetEmailRequestedEvent}), so a
+ * slow or unreachable mail server never adds latency to — or breaks — the
+ * sign-up, verification, or password-reset request itself.
+ */
+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/UserService.java b/src/main/java/com/weatherviewer/service/UserService.java
index 675aa89..481bdea 100644
--- a/src/main/java/com/weatherviewer/service/UserService.java
+++ b/src/main/java/com/weatherviewer/service/UserService.java
@@ -5,8 +5,9 @@
import com.weatherviewer.dto.UpdateUserRoleDto;
import com.weatherviewer.dto.UserDto;
import com.weatherviewer.model.User;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
-import java.util.List;
import java.util.UUID;
/**
@@ -25,8 +26,12 @@ public interface UserService {
*/
UUID create(CreateUserDto createUserDto);
- /** Returns every registered user account (admin use). */
- List
+ * Conversion happens here, at the presentation boundary, rather than by
+ * requesting a different unit system from the provider per user. That
+ * keeps a single shared cache entry per city/coordinate pair usable by
+ * every user regardless of their preference, instead of doubling cache
+ * entries (and provider requests) for every location.
+ */
+@Component
+public class UnitConverter {
+
+ private static final double CELSIUS_TO_FAHRENHEIT_MULTIPLIER = 9.0 / 5.0;
+ private static final double CELSIUS_TO_FAHRENHEIT_OFFSET = 32.0;
+ private static final double METERS_PER_SECOND_TO_MPH = 2.2369362921;
+
+ /**
+ * Returns {@code weather} unchanged if {@code units} is {@link UnitSystem#METRIC}
+ * (the storage/cache unit), or a converted copy if {@link UnitSystem#IMPERIAL}.
+ * The original {@code weather} instance is never mutated, since it may
+ * be a shared object backed by the cache.
+ */
+ public WeatherDto toDisplayUnits(WeatherDto weather, UnitSystem units) {
+ if (weather == null || units == UnitSystem.METRIC) {
+ return weather;
+ }
+
+ return new WeatherDto()
+ .setWeatherCondition(weather.getWeatherCondition())
+ .setTimeOfDay(weather.getTimeOfDay())
+ .setDescription(weather.getDescription())
+ .setTemperature(toFahrenheit(weather.getTemperature()))
+ .setTemperatureFeelsLike(toFahrenheit(weather.getTemperatureFeelsLike()))
+ .setTemperatureMinimum(toFahrenheit(weather.getTemperatureMinimum()))
+ .setTemperatureMaximum(toFahrenheit(weather.getTemperatureMaximum()))
+ .setHumidity(weather.getHumidity())
+ .setPressure(weather.getPressure())
+ .setWindSpeed(toMph(weather.getWindSpeed()))
+ .setWindDirection(weather.getWindDirection())
+ .setWindGust(toMph(weather.getWindGust()))
+ .setCloudiness(weather.getCloudiness())
+ .setDate(weather.getDate())
+ .setSunrise(weather.getSunrise())
+ .setSunset(weather.getSunset());
+ }
+
+ /** Applies {@link #toDisplayUnits(WeatherDto, UnitSystem)} to every entry in a list. */
+ public List
+ * Two things happen here that matter for reliability:
+ *
+ * Emails are plain text on purpose (no external images/styles to fetch,
+ * nothing for a mail client to block). Transient SMTP failures are retried
+ * via the {@code mail} Resilience4j retry instance (same pattern as the
+ * {@code weatherApi} retry around the OpenWeatherMap client) before giving
+ * up; a misconfigured or unreachable SMTP server never bubbles up past this
+ * class. Retry is applied programmatically here, rather than via
+ * {@code @Retry}, because the send happens on a private helper called from
+ * within this same bean — an annotation-based retry would be silently
+ * skipped by Spring AOP's proxy on that kind of self-invocation.
+ *
+ * Sending itself is now invoked off the request thread: {@link MailService}
+ * is only ever called by {@link MailEventListener}, asynchronously, after
+ * the transaction that created the underlying token has committed. That
+ * keeps a mail outage from adding latency to (or breaking) sign-up,
+ * verification, or password-reset requests.
+ */
+@Service
+@Slf4j
+public class MailServiceImpl implements MailService {
+
+ private final JavaMailSender mailSender;
+ private final Retry retry;
+ private final String fromAddress;
+
+ public MailServiceImpl(JavaMailSender mailSender,
+ RetryRegistry retryRegistry,
+ @Value("${app.mail.from:no-reply@weatherviewer.local}") String fromAddress) {
+ this.mailSender = mailSender;
+ this.retry = retryRegistry.retry("mail");
+ 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);
+ }
+
+ /**
+ * Sends the message, retrying transient SMTP failures via the {@code mail}
+ * Resilience4j retry instance. If every attempt fails, the failure is
+ * logged and swallowed here rather than propagated — callers (in
+ * practice, {@link MailEventListener} running on its own thread) never
+ * need to handle a mail-specific exception.
+ */
+ private void send(String to, String subject, String body) {
+ try {
+ retry.executeRunnable(() -> {
+ 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 {} after retries: {}", subject, to, e.getMessage());
+ }
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/service/impl/UserServiceImpl.java b/src/main/java/com/weatherviewer/service/impl/UserServiceImpl.java
index 2a02636..83d7598 100644
--- a/src/main/java/com/weatherviewer/service/impl/UserServiceImpl.java
+++ b/src/main/java/com/weatherviewer/service/impl/UserServiceImpl.java
@@ -9,14 +9,16 @@
import com.weatherviewer.mapper.UserMapper;
import com.weatherviewer.model.User;
import com.weatherviewer.model.enums.Role;
+import com.weatherviewer.model.enums.UnitSystem;
import com.weatherviewer.repository.UserRepository;
import com.weatherviewer.service.UserService;
import lombok.RequiredArgsConstructor;
+import org.springframework.data.domain.Page;
+import org.springframework.data.domain.Pageable;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
-import java.util.List;
import java.util.UUID;
/**
@@ -45,8 +47,8 @@ public UUID create(CreateUserDto createUserDto) {
@Override
@Transactional(readOnly = true)
- public List
+ * 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.
+ *
+ * The actual email is not sent from here. Instead, an
+ * {@code *EmailRequestedEvent} is published, which
+ * {@link MailEventListener} picks up asynchronously and only after this
+ * method's transaction has committed — so a rollback (or a slow/unreachable
+ * mail server) can never desync the token that was persisted from the link
+ * that was emailed.
+ */
+@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 ApplicationEventPublisher eventPublisher;
+ private final PasswordEncoder passwordEncoder;
+ private final String baseUrl;
+ private final long verificationTtlHours;
+ private final long passwordResetTtlHours;
+
+ public VerificationServiceImpl(UserRepository userRepository,
+ VerificationTokenRepository tokenRepository,
+ ApplicationEventPublisher eventPublisher,
+ 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.eventPublisher = eventPublisher;
+ 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();
+
+ eventPublisher.publishEvent(new VerificationEmailRequestedEvent(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();
+
+ eventPublisher.publishEvent(new PasswordResetEmailRequestedEvent(user.getEmail(), user.getFirstName(), link));
+ }, () -> log.info("Password reset requested for unregistered email={}", email));
+ }
+
+ @Override
+ @Transactional
+ public void resetPassword(String token, String newRawPassword) {
+ VerificationToken verificationToken = redeem(token, TokenType.PASSWORD_RESET);
+
+ User user = verificationToken.getUser();
+ user.setPassword(passwordEncoder.encode(newRawPassword));
+ userRepository.save(user);
+ }
+
+ /** Generates, persists, and returns the raw (unhashed) token string to embed in the emailed link. */
+ private String issueToken(User user, TokenType type, long ttlHours) {
+ byte[] randomBytes = new byte[32];
+ SECURE_RANDOM.nextBytes(randomBytes);
+ String rawToken = Base64.getUrlEncoder().withoutPadding().encodeToString(randomBytes);
+
+ VerificationToken verificationToken = new VerificationToken()
+ .setToken(rawToken)
+ .setUser(user)
+ .setType(type)
+ .setExpiresAt(LocalDateTime.now().plusHours(ttlHours))
+ .setUsed(false);
+
+ tokenRepository.save(verificationToken);
+ return rawToken;
+ }
+
+ /** Looks up a token by value/type, validates it, and marks it used — throwing if any of that fails. */
+ private VerificationToken redeem(String rawToken, TokenType type) {
+ VerificationToken verificationToken = tokenRepository.findByTokenAndType(rawToken, type)
+ .orElseThrow(() -> new InvalidTokenException("This link is invalid."));
+
+ if (!verificationToken.isValid()) {
+ throw new InvalidTokenException("This link has expired or was already used.");
+ }
+
+ verificationToken.setUsed(true);
+ tokenRepository.save(verificationToken);
+ return verificationToken;
+ }
+
+}
diff --git a/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java b/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java
index e0980b1..534bc6c 100644
--- a/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java
+++ b/src/main/java/com/weatherviewer/service/integration/WeatherApiClient.java
@@ -4,6 +4,8 @@
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.weatherviewer.exception.ExternalHttpCallException;
+import io.github.resilience4j.circuitbreaker.annotation.CircuitBreaker;
+import io.github.resilience4j.retry.annotation.Retry;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
@@ -14,6 +16,7 @@
import java.net.URI;
import java.nio.charset.StandardCharsets;
+import java.util.regex.Pattern;
/**
* Thin HTTP client for the OpenWeatherMap API (current weather, forecast,
@@ -30,6 +33,8 @@
@Slf4j
public class WeatherApiClient {
+ private static final Pattern APPID_PATTERN = Pattern.compile("(?i)([?&]appid=)[^&]*");
+
private final RestClient restClient;
private final ObjectMapper objectMapper;
@@ -48,7 +53,17 @@ public class WeatherApiClient {
@Value("${geo.api.url.suffix}")
private String geocodingApiUrlSuffix;
- /** Fetches current weather for a city by name. */
+ /**
+ * Fetches current weather for a city by name.
+ *
+ * Wrapped with the {@code weatherApi} retry (transient failures only,
+ * see {@link WeatherApiRetryPredicate}) and circuit breaker: once the
+ * provider is failing consistently, the breaker opens and short-circuits
+ * straight to {@link #fallbackWeatherByCity} instead of piling up more
+ * slow/failing calls.
+ */
+ @Retry(name = "weatherApi")
+ @CircuitBreaker(name = "weatherApi", fallbackMethod = "fallbackWeatherByCity")
public JsonNode fetchCurrentWeatherByCity(String city) {
log.debug("Building URL and fetching current weather for city: {}", city);
String url = buildCityUrl(weatherApiUrlSuffix, city);
@@ -56,6 +71,8 @@ public JsonNode fetchCurrentWeatherByCity(String city) {
}
/** Fetches current weather for a coordinate pair. */
+ @Retry(name = "weatherApi")
+ @CircuitBreaker(name = "weatherApi", fallbackMethod = "fallbackWeatherByCoordinates")
public JsonNode fetchCurrentWeatherByCoordinates(double latitude, double longitude) {
log.debug("Building URL and fetching current weather for lat: {}, lon: {}", latitude, longitude);
String url = buildCoordsUrl(weatherApiUrlSuffix, latitude, longitude);
@@ -63,6 +80,8 @@ public JsonNode fetchCurrentWeatherByCoordinates(double latitude, double longitu
}
/** Fetches the raw 3-hour-step forecast for a city by name. */
+ @Retry(name = "weatherApi")
+ @CircuitBreaker(name = "weatherApi", fallbackMethod = "fallbackForecastByCity")
public JsonNode fetchForecastByCity(String city) {
log.debug("Building URL and fetching forecast for city: {}", city);
String url = buildCityUrl(forecastApiUrlSuffix, city);
@@ -70,6 +89,8 @@ public JsonNode fetchForecastByCity(String city) {
}
/** Fetches the raw 3-hour-step forecast for a coordinate pair. */
+ @Retry(name = "weatherApi")
+ @CircuitBreaker(name = "weatherApi", fallbackMethod = "fallbackForecastByCoordinates")
public JsonNode fetchForecastByCoordinates(double latitude, double longitude) {
log.debug("Building URL and fetching forecast for lat: {}, lon: {}", latitude, longitude);
String url = buildCoordsUrl(forecastApiUrlSuffix, latitude, longitude);
@@ -77,12 +98,54 @@ public JsonNode fetchForecastByCoordinates(double latitude, double longitude) {
}
/** Geocodes a free-text city name into candidate locations. */
+ @Retry(name = "weatherApi")
+ @CircuitBreaker(name = "weatherApi", fallbackMethod = "fallbackGeocodingByCity")
public JsonNode fetchGeocodingByCity(String city) {
log.debug("Building URL and fetching geocoding data for city: {}", city);
String url = buildCityUrl(geocodingApiUrlSuffix, city);
return fetchJsonNode(url);
}
+ /**
+ * Fallback invoked once retries are exhausted or the {@code weatherApi}
+ * circuit breaker is open. Resilience4j matches this by parameter list
+ * (original method's args, plus the triggering {@link Throwable}), so
+ * each public fetch method needs its own overload here even though the
+ * bodies are identical.
+ */
+ private JsonNode fallbackWeatherByCity(String city, Throwable t) {
+ return handleFallback("current weather", "city=" + city, t);
+ }
+
+ private JsonNode fallbackWeatherByCoordinates(double latitude, double longitude, Throwable t) {
+ return handleFallback("current weather", "lat=" + latitude + ", lon=" + longitude, t);
+ }
+
+ private JsonNode fallbackForecastByCity(String city, Throwable t) {
+ return handleFallback("forecast", "city=" + city, t);
+ }
+
+ private JsonNode fallbackForecastByCoordinates(double latitude, double longitude, Throwable t) {
+ return handleFallback("forecast", "lat=" + latitude + ", lon=" + longitude, t);
+ }
+
+ private JsonNode fallbackGeocodingByCity(String city, Throwable t) {
+ return handleFallback("geocoding", "city=" + city, t);
+ }
+
+ /**
+ * Logs the exhausted call and surfaces a single, consistent
+ * {@link ExternalHttpCallException} regardless of whether we got here
+ * via a retryable exception running out of attempts or via an open
+ * circuit breaker ({@code CallNotPermittedException}). Callers (and
+ * {@link com.weatherviewer.exception.ControllerExceptionHandler}) only
+ * ever need to handle one exception type.
+ */
+ private JsonNode handleFallback(String operation, String params, Throwable t) {
+ log.error("Weather API {} call failed after retries/circuit breaker for {}: {}", operation, params, t.toString());
+ throw new ExternalHttpCallException("Weather service is temporarily unavailable, please try again shortly", false);
+ }
+
/**
* Issues the GET request and parses the response body as JSON.
*
@@ -97,36 +160,42 @@ private JsonNode fetchJsonNode(String url) {
return objectMapper.readTree(body);
} catch (JsonProcessingException e) {
String message = "Failed to parse JSON response from weather API service";
- log.error("{} for URL: {}", message, url, e);
+ log.error("{} for URL: {}", message, maskApiKey(url), e);
throw new ExternalHttpCallException(message);
} catch (RestClientResponseException e) {
- log.error("Weather API returned {} for URL: {}", e.getStatusCode(), url, e);
- throw new ExternalHttpCallException("Weather API error: " + e.getStatusCode());
+ log.error("Weather API returned {} for URL: {}", e.getStatusCode(), maskApiKey(url), e);
+ boolean retryable = e.getStatusCode() == null || !e.getStatusCode().is4xxClientError();
+ throw new ExternalHttpCallException("Weather API error: " + e.getStatusCode(), retryable);
} catch (Exception e) {
String message = "External HTTP call failed due to network or connection issues";
- log.error("{} for URL: {}", message, url, e);
+ log.error("{} for URL: {}", message, maskApiKey(url), e);
throw new ExternalHttpCallException(message);
}
}
- /** Builds a request URL for a city-name-based endpoint, with metric units and English descriptions. */
+ /** Masks the {@code appid} query parameter so the API key never reaches application logs. */
+ private static String maskApiKey(String url) {
+ return APPID_PATTERN.matcher(url).replaceAll("$1***");
+ }
+
+ /** Builds a request URL for a city-name-based endpoint, with metric units and locale-appropriate descriptions. */
private String buildCityUrl(String suffix, String city) {
return UriComponentsBuilder.fromUri(URI.create(baseApiUrl + suffix))
.queryParam("appid", apiKey)
.queryParam("units", "metric")
- .queryParam("lang", "en")
+ .queryParam("lang", WeatherApiLocale.resolve())
.queryParam("q", city)
.encode(StandardCharsets.UTF_8)
.build()
.toUriString();
}
- /** Builds a request URL for a coordinate-based endpoint, with metric units and English descriptions. */
+ /** Builds a request URL for a coordinate-based endpoint, with metric units and locale-appropriate descriptions. */
private String buildCoordsUrl(String suffix, double latitude, double longitude) {
return UriComponentsBuilder.fromUri(URI.create(baseApiUrl + suffix))
.queryParam("appid", apiKey)
.queryParam("units", "metric")
- .queryParam("lang", "en")
+ .queryParam("lang", WeatherApiLocale.resolve())
.queryParam("lat", latitude)
.queryParam("lon", longitude)
.encode(StandardCharsets.UTF_8)
diff --git a/src/main/java/com/weatherviewer/service/integration/WeatherApiLocale.java b/src/main/java/com/weatherviewer/service/integration/WeatherApiLocale.java
new file mode 100644
index 0000000..a89ab74
--- /dev/null
+++ b/src/main/java/com/weatherviewer/service/integration/WeatherApiLocale.java
@@ -0,0 +1,42 @@
+package com.weatherviewer.service.integration;
+
+import org.springframework.context.i18n.LocaleContextHolder;
+
+import java.util.Set;
+
+/**
+ * Maps the current request's locale to a language code OpenWeatherMap
+ * accepts for its {@code lang} query parameter, controlling the language of
+ * weather {@code description} text in the API response.
+ *
+ * Used by both {@link WeatherApiClient} (to build the actual request) and
+ * {@link WeatherApiCache} (to key cached responses per resolved language —
+ * without that, a location's weather cached for one language would be
+ * served as-is to a request in another language, since the raw JSON,
+ * descriptions included, is what's cached).
+ */
+public final class WeatherApiLocale {
+
+ /**
+ * Languages we ask OpenWeatherMap to translate descriptions into.
+ * Keep in sync with the {@code messages_XX.properties} bundles this
+ * application ships — no point requesting a language the UI itself
+ * can't render around.
+ */
+ private static final Set
+ * Transient failures — network errors, timeouts, malformed responses, and
+ * 5xx status codes — are retried. Client errors (bad request, invalid API
+ * key, unknown city) are marked non-retryable on the exception itself by
+ * {@link WeatherApiClient}, since retrying an inherently-wrong request
+ * three times only adds latency and burns the rate-limited provider quota
+ * without any chance of succeeding.
+ */
+public class WeatherApiRetryPredicate implements Predicate
+ * Without this, a single account (or a script hitting
+ * {@code /api/v1/locations/my} directly) could save an unbounded number
+ * of locations, and every dashboard load fans out one weather call per
+ * saved location. See {@link LocationLimitValidator}.
+ */
+@Documented
+@Constraint(validatedBy = LocationLimitValidator.class)
+@Target(ElementType.TYPE)
+@Retention(RetentionPolicy.RUNTIME)
+public @interface LocationLimit {
+
+ String message() default "Maximum number of saved locations reached";
+
+ Class>[] groups() default {};
+
+ Class extends Payload>[] payload() default {};
+
+}
diff --git a/src/main/java/com/weatherviewer/validation/validator/LocationLimitValidator.java b/src/main/java/com/weatherviewer/validation/validator/LocationLimitValidator.java
new file mode 100644
index 0000000..d563157
--- /dev/null
+++ b/src/main/java/com/weatherviewer/validation/validator/LocationLimitValidator.java
@@ -0,0 +1,51 @@
+package com.weatherviewer.validation.validator;
+
+import com.weatherviewer.dto.AddLocationDto;
+import com.weatherviewer.service.LocationService;
+import com.weatherviewer.validation.annotation.LocationLimit;
+import jakarta.validation.ConstraintValidator;
+import jakarta.validation.ConstraintValidatorContext;
+import lombok.RequiredArgsConstructor;
+import org.springframework.beans.factory.annotation.Value;
+import org.springframework.stereotype.Component;
+
+/**
+ * Backs {@link LocationLimit}. Counts how many locations the owning user
+ * already has saved and rejects the new one if that count is already at
+ * or above {@code location.max-per-user}.
+ *
+ * Skips the check (returns valid) when {@code userId} is missing, since
+ * that case is already reported by the DTO's own required-field
+ * validation and shouldn't also surface a misleading "limit reached"
+ * message.
+ */
+@Component
+@RequiredArgsConstructor
+public class LocationLimitValidator implements ConstraintValidator We're sorry, but an unexpected error has occurred. Please try again later. We're sorry, but an unexpected error has occurred. Please try again later. Copyright © 2025 Pidlisnyi Lev Copyright © 2025 Pidlisnyi Lev Enter the email address on your account and we'll send you a link to reset your password. Location Required for Weather Information Please add a location to view weather. You can do it by using the search bar above. Location Required for Weather Information Please add a location to view weather. You can do it by using the search bar above.
+ *
+ * {@link MailService} itself still retries transient SMTP failures and
+ * never throws, so a failure here is logged by it directly and does not
+ * propagate any further.
+ */
+@Component
+@RequiredArgsConstructor
+@Slf4j
+public class MailEventListener {
+
+ private final MailService mailService;
+
+ @Async("mailTaskExecutor")
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void onVerificationEmailRequested(VerificationEmailRequestedEvent event) {
+ log.debug("Dispatching verification email to {}", event.email());
+ mailService.sendVerificationEmail(event.email(), event.firstName(), event.verificationLink());
+ }
+
+ @Async("mailTaskExecutor")
+ @TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
+ public void onPasswordResetEmailRequested(PasswordResetEmailRequestedEvent event) {
+ log.debug("Dispatching password reset email to {}", event.email());
+ mailService.sendPasswordResetEmail(event.email(), event.firstName(), event.resetLink());
+ }
+
+}
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..47bc009
--- /dev/null
+++ b/src/main/java/com/weatherviewer/service/impl/MailServiceImpl.java
@@ -0,0 +1,96 @@
+package com.weatherviewer.service.impl;
+
+import com.weatherviewer.service.MailService;
+import io.github.resilience4j.retry.Retry;
+import io.github.resilience4j.retry.RetryRegistry;
+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}.
+ * Oops! Something Went Wrong
- Oops! Something Went Wrong
+
- Hourly Forecast
+ Hourly Forecast
-
+
Daily Forecast
+
Daily Forecast
-
-
+
+
-
+
+
\ No newline at end of file
diff --git a/src/main/resources/templates/forgot-password.html b/src/main/resources/templates/forgot-password.html
new file mode 100644
index 0000000..6c20372
--- /dev/null
+++ b/src/main/resources/templates/forgot-password.html
@@ -0,0 +1,45 @@
+
+
+
+
+
+
Forgot password
+
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 @@
-
+
+
+
+
Weather Viewer
Current Weather
+ Current Weather
Location
Location
th:src="@{'/images/' + ${item.value.weatherCondition} + '-' + ${item.value.timeOfDay} + '.svg'}">
-
Account Settings
+ Account Settings
Account Settings
+
diff --git a/src/main/resources/templates/reset-password.html b/src/main/resources/templates/reset-password.html
new file mode 100644
index 0000000..2967b2a
--- /dev/null
+++ b/src/main/resources/templates/reset-password.html
@@ -0,0 +1,51 @@
+
+
+
+
+
+Reset password
+
+ Search results
+ Search results
Name
State
- No data
+ No data
Name
+