Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -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"]
3 changes: 2 additions & 1 deletion docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
4 changes: 4 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -94,6 +94,10 @@
<groupId>io.micrometer</groupId>
<artifactId>micrometer-tracing-bridge-brave</artifactId>
</dependency>
<dependency>
<groupId>io.micrometer</groupId>
<artifactId>micrometer-registry-prometheus</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
Expand Down
64 changes: 62 additions & 2 deletions src/main/java/com/weatherviewer/config/SecurityConfig.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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.
* <p>
Expand All @@ -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
Expand Down Expand Up @@ -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
Expand All @@ -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:;"
)
Expand Down Expand Up @@ -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;
}

}
15 changes: 12 additions & 3 deletions src/main/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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:-}]
1 change: 1 addition & 0 deletions src/main/resources/liquibase/changelog.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,6 @@
<include file="/liquibase/update/03-add-location-indexes.xml"/>
<include file="/liquibase/update/04-add-user-units.xml"/>
<include file="/liquibase/update/05-add-verification-tokens.xml"/>
<include file="/liquibase/update/06-add-persistent-logins.xml"/>

</databaseChangeLog>
29 changes: 29 additions & 0 deletions src/main/resources/liquibase/update/06-add-persistent-logins.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<databaseChangeLog logicalFilePath="liquibase/changelog.xml" xmlns="http://www.liquibase.org/xml/ns/dbchangelog"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://www.liquibase.org/xml/ns/dbchangelog https://www.liquibase.org/xml/ns/dbchangelog/dbchangelog-3.4.xsd">

<changeSet id="ct-persistent-logins" author="Pidlisnyi Lev">
<createTable tableName="persistent_logins">
<column name="username" type="VARCHAR(64)">
<constraints nullable="false"/>
</column>
<column name="series" type="VARCHAR(64)">
<constraints nullable="false" primaryKey="true" unique="true"/>
</column>
<column name="token" type="VARCHAR(64)">
<constraints nullable="false"/>
</column>
<column name="last_used" type="TIMESTAMP">
<constraints nullable="false"/>
</column>
</createTable>
</changeSet>

<changeSet id="idx-persistent-logins-username" author="Pidlisnyi Lev">
<createIndex tableName="persistent_logins" indexName="idx_persistent_logins_username">
<column name="username"/>
</createIndex>
</changeSet>

</databaseChangeLog>
6 changes: 5 additions & 1 deletion src/main/resources/templates/sign-in.html
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,11 @@ <h3 class="my-4 text-center">Sign in</h3>
<input type="password" class="form-control" id="password" name="password" placeholder="Password" required>
<label for="password">Password</label>
</div>
<div class="text-end mb-4">
<div class="d-flex justify-content-between align-items-center mb-4">
<div class="form-check">
<input type="checkbox" class="form-check-input" id="remember-me" name="remember-me" value="true">
<label class="form-check-label small" for="remember-me">Remember me</label>
</div>
<a th:href="@{/forgot-password}" class="small">Forgot password?</a>
</div>
<div>
Expand Down
4 changes: 4 additions & 0 deletions src/test/resources/application.properties
Original file line number Diff line number Diff line change
Expand Up @@ -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