Commit 940c84c0 by linxu

feat(security): add brute-force login protection

Implement login attempt tracking and rate limiting to prevent
brute-force attacks:

- Add LoginAttemptService to track failed attempts per username
  with configurable max attempts (5), lockout duration (30min),
  and attempt window (15min)
- Add LoginAttemptFilter to block requests before authentication
  when account is locked, returns HTTP 429
- Add AuthenticationFailureListener to record failed attempts
- Add AuthenticationSuccessListener to clear attempts on success
- Update RESTAuthenticationFailureHandler to return generic
  'Invalid credentials' message to prevent username enumeration
- Update SsoSecurityConfig to add filter before authentication
- Add security.login.* configuration properties to application.yml

The implementation uses in-memory tracking with automatic cleanup
after lockout period expires.
parent 5b71f719
# Agent Guidelines for loginservice
## Project Overview
Java Spring Boot SSO (Single Sign-On) authentication service. Part of the KeyMobile product suite. Uses internal auth libraries (`com.keymobile.authservice`).
## Build Commands
| Command | Description |
|---------|-------------|
| `mvn clean compile` | Compile the project |
| `mvn clean package` | Build JAR and assembly distribution |
| `mvn clean package -DskipTests` | Build without running tests |
| `mvn test` | Run all tests |
| `mvn test -Dtest=ClassName` | Run a single test class |
| `mvn test -Dtest=ClassName#methodName` | Run a single test method |
| `mvn spring-boot:run` | Run the application locally |
## Tech Stack
- Java 17
- Spring Boot (via parent POM)
- Spring Security
- Spring Cloud (Eureka discovery client)
- Maven
- Apache Commons Lang
- Jasypt for property encryption
## Code Style
### Formatting
- 4-space indentation
- Opening braces on the same line
- No trailing whitespace
- No wildcard imports
### Imports
- Group: `java.*`, `jakarta.*`, `org.*`, `com.*`
- No wildcard imports; always use explicit class names
- Internal auth service imports: `com.keymobile.authservice.*`
- Config/logging imports: `com.keymobile.config.*`
### Naming
- Classes: `PascalCase` (e.g., `SsoSecurityConfig`, `LoginManagement`)
- Methods/variables: `camelCase`
- Constants: `UPPER_SNAKE_CASE` (e.g., `Session_UserId`)
- Package: `com.keymobile.sso.*`
### Types
- Use explicit types over `var`
- Use `Map<String, Object>` for API responses
- Use `List<String>` for collections
- Prefer `jakarta.servlet.*` over `javax.servlet.*`
### Error Handling
- Use `@ControllerAdvice` with `@ExceptionHandler` for global exception handling
- Custom exceptions extend `Exception` (e.g., `LoginException`)
- API errors use `ApiError` class with `message` and `cnMessage` fields
- Log errors via `LogManager` utility (not directly via SLF4J)
### Logging
- Use `LogManager` utility class for all logging
- Context constants defined in `LogConstants`
- Example: `LogManager.logInfo(LogConstants.CTX_AUDIT, "message")`
## Architecture Patterns
### Security
- Security config in `conf/` package extending Spring Security
- Custom handlers for auth success/failure/entry point/logout
- Password encoder is plaintext (legacy behavior)
- CSRF disabled
### Configuration
- `SystemVariable` reads JVM system properties at startup
- License checking controlled by `disableLicenseCheck` property
- Application config in `application.yml` (port 8764, app name `auth`)
### API Controllers
- Use `@RestController` with `@RequestMapping`
- Session info endpoint: `/sessionInfo`
- Language endpoint: `/lang`
- Principal format: `username:userId:displayName` (colon-delimited)
## Testing
- No existing test files in the repository
- When adding tests, place them in `src/test/java/com/keymobile/sso/`
- Use JUnit 5 (JUnit Jupiter) since parent POM uses Spring Boot 3.x
- Use `@SpringBootTest` for integration tests
- Run single test: `mvn test -Dtest=ClassName#methodName`
## Important Notes
- **No tests exist currently** - the project has zero test coverage
- **No linting tools configured** - no Checkstyle, SpotBugs, or PMD
- **License checking** is baked into auth success handler
- **Hardcoded AES key/IV** in `LicenseChecker` and `LicenseMgr`
- Parent POM version: `product-v1-1.0.4-rc1`
- Auth library version: `product-v2-1.0.3-rc4`
......@@ -8,6 +8,7 @@ import org.springframework.security.web.authentication.SimpleUrlAuthenticationFa
import org.springframework.stereotype.Component;
import java.io.IOException;
import java.io.PrintWriter;
@Component
public class RESTAuthenticationFailureHandler extends SimpleUrlAuthenticationFailureHandler {
......@@ -15,7 +16,11 @@ public class RESTAuthenticationFailureHandler extends SimpleUrlAuthenticationFai
@Override
public void onAuthenticationFailure(HttpServletRequest request, HttpServletResponse response,
AuthenticationException exception) throws IOException, ServletException {
super.onAuthenticationFailure(request, response, exception);
response.setStatus(HttpServletResponse.SC_UNAUTHORIZED);
PrintWriter writer = response.getWriter();
writer.write("Invalid credentials");
writer.flush();
writer.close();
}
}
\ No newline at end of file
package com.keymobile.sso.conf;
import com.keymobile.sso.security.LoginAttemptFilter;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.annotation.Bean;
import org.springframework.context.annotation.Configuration;
......@@ -7,6 +8,7 @@ import org.springframework.security.config.annotation.web.builders.HttpSecurity;
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
import org.springframework.security.crypto.password.PasswordEncoder;
import org.springframework.security.web.SecurityFilterChain;
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
@Configuration
......@@ -21,6 +23,8 @@ public class SsoSecurityConfig {
private RESTAuthenticationSuccessHandler authenticationSuccessHandler;
@Autowired
private RESTLogoutSuccessHandler logoutSuccessHandler;
@Autowired
private LoginAttemptFilter loginAttemptFilter;
@Bean
public PasswordEncoder passwordEncoder() {
......@@ -56,6 +60,7 @@ public class SsoSecurityConfig {
logout.logoutUrl("/signout");
logout.logoutSuccessHandler(logoutSuccessHandler);
});
http.addFilterBefore(loginAttemptFilter, UsernamePasswordAuthenticationFilter.class);
return http.build();
}
......
package com.keymobile.sso.exception;
public class LoginException extends Exception {
public LoginException(String msg) {
super(msg);
}
public String getCnMessage() {
return null;
}
}
package com.keymobile.sso.security;
import com.keymobile.sso.logging.LogConstants;
import com.keymobile.sso.logging.LogManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.security.authentication.event.AuthenticationFailureBadCredentialsEvent;
import org.springframework.stereotype.Component;
@Component
public class AuthenticationFailureListener implements ApplicationListener<AuthenticationFailureBadCredentialsEvent> {
@Autowired
private LoginAttemptService loginAttemptService;
@Override
public void onApplicationEvent(AuthenticationFailureBadCredentialsEvent event) {
String username = event.getAuthentication().getName();
loginAttemptService.loginFailed(username);
LogManager.logWarning(LogConstants.CTX_AUDIT, "Failed login attempt for user: " + username);
}
}
package com.keymobile.sso.security;
import com.keymobile.sso.logging.LogConstants;
import com.keymobile.sso.logging.LogManager;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.context.ApplicationListener;
import org.springframework.security.authentication.event.AuthenticationSuccessEvent;
import org.springframework.stereotype.Component;
@Component
public class AuthenticationSuccessListener implements ApplicationListener<AuthenticationSuccessEvent> {
@Autowired
private LoginAttemptService loginAttemptService;
@Override
public void onApplicationEvent(AuthenticationSuccessEvent event) {
String username = event.getAuthentication().getName();
loginAttemptService.loginSucceeded(username);
LogManager.logInfo(LogConstants.CTX_AUDIT, "Successful login for user: " + username + ", cleared failed attempts");
}
}
package com.keymobile.sso.security;
import com.keymobile.sso.logging.LogConstants;
import com.keymobile.sso.logging.LogManager;
import jakarta.servlet.FilterChain;
import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.web.filter.OncePerRequestFilter;
import java.io.IOException;
@Component
public class LoginAttemptFilter extends OncePerRequestFilter {
@Autowired
private LoginAttemptService loginAttemptService;
@Override
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
throws ServletException, IOException {
if (isLoginRequest(request)) {
String username = request.getParameter("username");
if (username != null && !username.isEmpty()) {
if (loginAttemptService.isBlocked(username)) {
LogManager.logWarning(LogConstants.CTX_AUDIT, "Blocked login attempt for locked user: " + username);
response.setStatus(429); // HTTP 429 Too Many Requests
response.getWriter().write("Account is temporarily locked due to too many failed login attempts. Please try again later.");
response.getWriter().flush();
return;
}
}
}
filterChain.doFilter(request, response);
}
private boolean isLoginRequest(HttpServletRequest request) {
return "/signin".equals(request.getRequestURI()) && "POST".equalsIgnoreCase(request.getMethod());
}
}
package com.keymobile.sso.security;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Service;
import java.time.LocalDateTime;
import java.time.temporal.ChronoUnit;
import java.util.Map;
import java.util.concurrent.ConcurrentHashMap;
@Service
public class LoginAttemptService {
@Value("${security.login.max-attempts:5}")
private int maxAttempts;
@Value("${security.login.lockout-duration:30}")
private int lockoutDurationMinutes;
@Value("${security.login.attempt-window:15}")
private int attemptWindowMinutes;
private final Map<String, LoginAttempt> attempts = new ConcurrentHashMap<>();
public void loginFailed(String key) {
LoginAttempt attempt = attempts.computeIfAbsent(key, k -> new LoginAttempt());
attempt.incrementAttempts();
}
public void loginSucceeded(String key) {
attempts.remove(key);
}
public boolean isBlocked(String key) {
LoginAttempt attempt = attempts.get(key);
if (attempt == null) {
return false;
}
if (attempt.isLocked()) {
if (attempt.getLockTime().plus(lockoutDurationMinutes, ChronoUnit.MINUTES).isBefore(LocalDateTime.now())) {
attempts.remove(key);
return false;
}
return true;
}
if (attempt.getAttempts() >= maxAttempts) {
attempt.lock();
return true;
}
if (attempt.getFirstAttemptTime().plus(attemptWindowMinutes, ChronoUnit.MINUTES).isBefore(LocalDateTime.now())) {
attempts.remove(key);
return false;
}
return false;
}
public int getRemainingAttempts(String key) {
LoginAttempt attempt = attempts.get(key);
if (attempt == null || attempt.isLocked()) {
return 0;
}
return Math.max(0, maxAttempts - attempt.getAttempts());
}
private static class LoginAttempt {
private int attempts;
private LocalDateTime firstAttemptTime;
private LocalDateTime lockTime;
private boolean locked;
LoginAttempt() {
this.attempts = 0;
this.firstAttemptTime = LocalDateTime.now();
this.locked = false;
}
void incrementAttempts() {
this.attempts++;
}
int getAttempts() {
return attempts;
}
LocalDateTime getFirstAttemptTime() {
return firstAttemptTime;
}
void lock() {
this.locked = true;
this.lockTime = LocalDateTime.now();
}
boolean isLocked() {
return locked;
}
LocalDateTime getLockTime() {
return lockTime;
}
}
}
......@@ -24,6 +24,12 @@ management:
exposure:
include: prometheus
security:
login:
max-attempts: 5
lockout-duration: 30
attempt-window: 15
logging:
level:
root: info
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment