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
30 changes: 30 additions & 0 deletions pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
<hibernate.core.version>6.6.7.Final</hibernate.core.version>
<hibernate.jpa.version>1.0.1.Final</hibernate.jpa.version>
<jacoco.version>0.8.12</jacoco.version>
<resilience4j.version>2.2.0</resilience4j.version>
<mapstruct-processor.version>1.5.5.Final</mapstruct-processor.version>
<lombok-mapstruct-binding.version>0.2.0</lombok-mapstruct-binding.version>
</properties>
Expand Down Expand Up @@ -102,6 +103,15 @@
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-mail</artifactId>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-aop</artifactId>
</dependency>
<dependency>
<groupId>io.github.resilience4j</groupId>
<artifactId>resilience4j-spring-boot3</artifactId>
<version>${resilience4j.version}</version>
</dependency>
<dependency>
<groupId>org.projectlombok</groupId>
<artifactId>lombok</artifactId>
Expand Down Expand Up @@ -214,6 +224,26 @@
<goal>report</goal>
</goals>
</execution>
<execution>
<id>check</id>
<goals>
<goal>check</goal>
</goals>
<configuration>
<rules>
<rule>
<element>BUNDLE</element>
<limits>
<limit>
<counter>LINE</counter>
<value>COVEREDRATIO</value>
<minimum>0.90</minimum>
</limit>
</limits>
</rule>
</rules>
</configuration>
</execution>
</executions>
</plugin>
<plugin>
Expand Down
20 changes: 17 additions & 3 deletions src/main/java/com/weatherviewer/config/AppConfig.java
Original file line number Diff line number Diff line change
@@ -1,7 +1,9 @@
package com.weatherviewer.config;

import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
import org.springframework.http.client.SimpleClientHttpRequestFactory;
import org.springframework.security.web.DefaultRedirectStrategy;
import org.springframework.security.web.RedirectStrategy;
import org.springframework.web.client.RestClient;
Expand All @@ -17,10 +19,22 @@
@Configuration
public class AppConfig {

/** HTTP client used by {@link com.weatherviewer.service.integration.WeatherApiClient} to call OpenWeatherMap. */
/**
* HTTP client used by {@link com.weatherviewer.service.integration.WeatherApiClient} to
* call OpenWeatherMap. Explicit connect/read timeouts are required here: without them the
* underlying JDK client factory has no default timeout, so a hung or slow OpenWeatherMap
* response would block the calling request thread indefinitely and, under load, exhaust
* the server's thread pool.
*/
@Bean
public RestClient restClient(RestClient.Builder restClientBuilder) {
return restClientBuilder.build();
public RestClient restClient(
RestClient.Builder restClientBuilder,
@Value("${weather.api.connect-timeout-ms:3000}") int connectTimeoutMs,
@Value("${weather.api.read-timeout-ms:5000}") int readTimeoutMs) {
SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory();
requestFactory.setConnectTimeout(connectTimeoutMs);
requestFactory.setReadTimeout(readTimeoutMs);
return restClientBuilder.requestFactory(requestFactory).build();
}

/** Default Spring Security redirect strategy, used by {@link com.weatherviewer.security.CustomAuthSuccessHandler}. */
Expand Down
13 changes: 10 additions & 3 deletions src/main/java/com/weatherviewer/controller/AuthController.java
Original file line number Diff line number Diff line change
Expand Up @@ -49,15 +49,22 @@ 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(@RequestParam(required = false) Boolean unverified,
@RequestParam(required = false) Boolean locked,
@RequestParam(required = false) String email,
RedirectAttributes redirectAttributes) {
log.info("Sign-in failed, unverified={}", unverified);
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"
+ (email != null && !email.isBlank() ? "&email=" + email : "");
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.");
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.weatherviewer.exception;

import lombok.Getter;

/**
* Thrown when a call to an external HTTP service fails — in practice, the
* OpenWeatherMap API accessed by
Expand All @@ -8,13 +10,33 @@
* {@link ControllerExceptionHandler} into a {@code 503 Service Unavailable}
* response.
*/
@Getter
public class ExternalHttpCallException extends RuntimeException {

/**
* Whether this failure is worth retrying. Network errors, timeouts, and
* 5xx responses from the provider are transient and {@code retryable};
* 4xx responses (bad request, unauthorized, not found) reflect a
* problem with the request itself and won't succeed on a second try, so
* they're marked {@code false} and skipped by
* {@link com.weatherviewer.service.integration.WeatherApiRetryPredicate}.
*/
private final boolean retryable;

/**
* @param message description of what went wrong calling the external service
*/
public ExternalHttpCallException(String message) {
this(message, true);
}

/**
* @param message description of what went wrong calling the external service
* @param retryable whether a retry might succeed
*/
public ExternalHttpCallException(String message, boolean retryable) {
super(message);
this.retryable = retryable;
}

}
18 changes: 18 additions & 0 deletions src/main/java/com/weatherviewer/model/User.java
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
import org.hibernate.annotations.JdbcType;
import org.hibernate.dialect.PostgreSQLEnumJdbcType;

import java.time.LocalDateTime;
import java.util.List;

/**
Expand Down Expand Up @@ -57,6 +58,23 @@ public class User extends BaseEntity {
@JdbcType(PostgreSQLEnumJdbcType.class)
private UnitSystem units = UnitSystem.METRIC;

/**
* Consecutive failed sign-in attempts since the last successful login
* or the last time the account was unlocked. Reset to zero on every
* successful authentication. Tracked by
* {@link com.weatherviewer.security.AccountLockoutListener}.
*/
@Column(nullable = false)
private int failedLoginAttempts = 0;

/**
* If set and still in the future, the account is temporarily locked out
* of authentication regardless of {@link #status} — see
* {@link com.weatherviewer.security.SecUser#isAccountNonLocked()}.
* {@code null} means the account isn't locked.
*/
private LocalDateTime lockedUntil;

/** Locations saved by this user; removed automatically if the user is deleted. */
@OneToMany(mappedBy = "user", cascade = CascadeType.ALL, orphanRemoval = true)
private List<Location> locations;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
package com.weatherviewer.security;

import com.weatherviewer.repository.UserRepository;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.context.event.EventListener;
import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.stereotype.Component;
import org.springframework.transaction.annotation.Transactional;

import java.time.LocalDateTime;

/**
* Tracks consecutive failed sign-in attempts per account and applies a
* temporary lockout once a threshold is reached, mitigating credential
* stuffing / brute-force attempts against a single known email address —
* something IP- or session-based rate limiting alone doesn't fully cover,
* since an attacker can spread attempts across many IPs.
* <p>
* 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);
}
});
}

}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
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;
Expand All @@ -22,6 +23,12 @@
* 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".
* <p>
* 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 {
Expand All @@ -33,13 +40,23 @@ public CustomAuthFailureHandler() {
@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("unverified", "true");
.queryParam(param, "true");

String email = request.getParameter("email");
if (email != null && !email.isBlank()) {
Expand Down
16 changes: 11 additions & 5 deletions src/main/java/com/weatherviewer/security/SecUser.java
Original file line number Diff line number Diff line change
Expand Up @@ -9,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;
Expand All @@ -17,11 +18,14 @@
* Spring Security {@link UserDetails} adapter around this application's
* {@link User} entity.
* <p>
* 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
Expand All @@ -34,6 +38,7 @@ public class SecUser implements UserDetails {
private final Boolean isActive;
private final String fullName;
private final UnitSystem units;
private final LocalDateTime lockedUntil;

@Override
public Collection<? extends GrantedAuthority> getAuthorities() {
Expand All @@ -57,7 +62,7 @@ public boolean isAccountNonExpired() {

@Override
public boolean isAccountNonLocked() {
return isActive;
return isActive && (lockedUntil == null || lockedUntil.isBefore(LocalDateTime.now()));
}

@Override
Expand All @@ -84,7 +89,8 @@ public static SecUser fromUser(User user) {
user.getRole().getAuthority(),
user.getStatus() == UserStatus.ACTIVE,
user.getFullName(),
user.getUnits()
user.getUnits(),
user.getLockedUntil()
);
}

Expand Down
Loading
Loading