diff --git a/Dockerfile b/Dockerfile index c1ac92a..fd006b1 100644 --- a/Dockerfile +++ b/Dockerfile @@ -13,7 +13,7 @@ RUN addgroup -S appgroup && adduser -S appuser -G appgroup COPY --from=build /app/target/*.jar app.jar RUN chown -R appuser:appgroup /app USER appuser -EXPOSE 8080 +EXPOSE 8080 8081 HEALTHCHECK --interval=15s --timeout=5s --start-period=30s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:8080/actuator/health || exit 1 + CMD wget --no-verbose --tries=1 --spider http://localhost:8081/actuator/health || exit 1 ENTRYPOINT ["java", "-XX:+UseG1GC", "-XX:+UseContainerSupport", "-jar", "app.jar"] diff --git a/docker-compose.yml b/docker-compose.yml index 7cccee4..0d395dc 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -8,13 +8,14 @@ services: 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 f37e03d..3d85ea4 100644 --- a/pom.xml +++ b/pom.xml @@ -94,6 +94,10 @@ io.micrometer micrometer-tracing-bridge-brave + + io.micrometer + micrometer-registry-prometheus + org.springframework.boot spring-boot-starter-mail diff --git a/src/main/java/com/weatherviewer/config/SecurityConfig.java b/src/main/java/com/weatherviewer/config/SecurityConfig.java index af0cfb5..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; @@ -13,12 +14,18 @@ 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. *

@@ -38,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 @@ -81,7 +103,8 @@ 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 @@ -90,13 +113,17 @@ public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Excepti .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; " + + "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:;" ) @@ -132,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/resources/application.properties b/src/main/resources/application.properties index f273614..1e10548 100644 --- a/src/main/resources/application.properties +++ b/src/main/resources/application.properties @@ -20,6 +20,10 @@ spring.jpa.properties.hibernate.jdbc.time_zone=UTC server.servlet.session.cookie.secure=true server.servlet.session.cookie.same-site=strict +# --- Remember-me --- +security.remember-me.key=${REMEMBER_ME_KEY} +security.remember-me.token-validity-seconds=1209600 + spring.data.web.pageable.default-page-size=20 spring.data.web.pageable.max-page-size=100 spring.data.web.pageable.one-indexed-parameters=false @@ -74,13 +78,18 @@ 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 +# --- Actuator / health / metrics --- +management.server.port=${MANAGEMENT_SERVER_PORT:8081} +management.endpoints.web.exposure.include=health,prometheus,info management.endpoint.health.show-details=when-authorized management.endpoint.health.probes.enabled=true management.health.livenessstate.enabled=true management.health.readinessstate.enabled=true +management.metrics.tags.application=${spring.application.name} +management.metrics.distribution.percentiles-histogram.http.server.requests=true +management.metrics.distribution.slo.http.server.requests=50ms,100ms,200ms,500ms,1s,2s + # --- Graceful shutdown --- server.shutdown=graceful spring.lifecycle.timeout-per-shutdown-phase=20s @@ -112,4 +121,4 @@ rate-limit.rules[4].limit=60 rate-limit.rules[4].window-seconds=60 management.tracing.sampling.probability=1.0 -logging.pattern.correlation=[${spring.application.name:-},%X{traceId:-},%X{spanId:-}] +logging.pattern.correlation=[${spring.application.name:-},%X{traceId:-},%X{spanId:-}] \ No newline at end of file diff --git a/src/main/resources/liquibase/changelog.xml b/src/main/resources/liquibase/changelog.xml index 0a4206f..51be9e7 100644 --- a/src/main/resources/liquibase/changelog.xml +++ b/src/main/resources/liquibase/changelog.xml @@ -8,5 +8,6 @@ + diff --git a/src/main/resources/liquibase/update/06-add-persistent-logins.xml b/src/main/resources/liquibase/update/06-add-persistent-logins.xml new file mode 100644 index 0000000..1824135 --- /dev/null +++ b/src/main/resources/liquibase/update/06-add-persistent-logins.xml @@ -0,0 +1,29 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + \ 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 2cf9ef1..c64bd4d 100644 --- a/src/main/resources/templates/sign-in.html +++ b/src/main/resources/templates/sign-in.html @@ -26,7 +26,11 @@

Sign in

-
+
+
+ + +
Forgot password?
diff --git a/src/test/resources/application.properties b/src/test/resources/application.properties index a674d26..f175cc6 100644 --- a/src/test/resources/application.properties +++ b/src/test/resources/application.properties @@ -22,3 +22,7 @@ spring.cache.type=simple # --- Mail --- spring.mail.host=localhost spring.mail.port=1025 + +# --- Remember-me --- +security.remember-me.key=test-remember-me-key-not-for-production +security.remember-me.token-validity-seconds=1209600 \ No newline at end of file