Public Access
fix(login): same error for every failed login, block disabled users, rate limit
Unknown email, wrong password and disabled account now all return 401 INVALID_CREDENTIALS; unknown emails still run bcrypt against a dummy hash so timing does not leak them. After 5 failures per email or 20 per client IP, login returns 429 with Retry-After for 15 minutes. LoginRequest is now validated.
This commit is contained in:
1 parent
3d21bb667d
commit
5512453774
11 files changed
+293
-37
No files matched your search
@@ -5,6 +5,8 @@ import auth_service.modules.login.api.dto.response.LoginResponse;
|
|||||||
import auth_service.modules.login.api.mapper.ApiMapper;
|
import auth_service.modules.login.api.mapper.ApiMapper;
|
||||||
import auth_service.modules.login.service.LoginService;
|
import auth_service.modules.login.service.LoginService;
|
||||||
import io.micrometer.core.annotation.Timed;
|
import io.micrometer.core.annotation.Timed;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.validation.Valid;
|
||||||
import lombok.RequiredArgsConstructor;
|
import lombok.RequiredArgsConstructor;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
@@ -30,10 +32,23 @@ public class LoginController {
|
|||||||
*/
|
*/
|
||||||
@PostMapping("/login")
|
@PostMapping("/login")
|
||||||
@Timed(value = "auth.login.requests.time", description = "Time taken to process login requests")
|
@Timed(value = "auth.login.requests.time", description = "Time taken to process login requests")
|
||||||
public ResponseEntity<LoginResponse> login(@RequestBody LoginRequest request) {
|
public ResponseEntity<LoginResponse> login(@Valid @RequestBody LoginRequest request, HttpServletRequest httpRequest) {
|
||||||
|
|
||||||
var user = ApiMapper.toModel(request);
|
var user = ApiMapper.toModel(request);
|
||||||
var token = loginService.login(user);
|
var token = loginService.login(user, clientIp(httpRequest));
|
||||||
return ResponseEntity.ok(new LoginResponse("Login successful", token));
|
return ResponseEntity.ok(new LoginResponse("Login successful", token));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Traefik appends the real client IP as the last X-Forwarded-For entry. Earlier entries come
|
||||||
|
* from the client and can be faked, so only the last one counts.
|
||||||
|
*/
|
||||||
|
private static String clientIp(HttpServletRequest request) {
|
||||||
|
String forwardedFor = request.getHeader("X-Forwarded-For");
|
||||||
|
if (forwardedFor == null || forwardedFor.isBlank()) {
|
||||||
|
return request.getRemoteAddr();
|
||||||
|
}
|
||||||
|
String[] hops = forwardedFor.split(",");
|
||||||
|
return hops[hops.length - 1].trim();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@@ -2,10 +2,11 @@ package auth_service.modules.login.api;
|
|||||||
|
|
||||||
import auth_service.shared.api.dto.response.ErrorResponse;
|
import auth_service.shared.api.dto.response.ErrorResponse;
|
||||||
import auth_service.modules.login.exception.InvalidCredentialsException;
|
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||||
import auth_service.modules.login.exception.UserNotFoundException;
|
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||||
import lombok.NonNull;
|
import lombok.NonNull;
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.core.annotation.Order;
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.http.HttpHeaders;
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.http.ResponseEntity;
|
import org.springframework.http.ResponseEntity;
|
||||||
import org.springframework.web.bind.annotation.ExceptionHandler;
|
import org.springframework.web.bind.annotation.ExceptionHandler;
|
||||||
@@ -28,10 +29,13 @@ public class LoginExceptionController {
|
|||||||
/*
|
/*
|
||||||
######## CLIENT ERRORS (400-499)
|
######## CLIENT ERRORS (400-499)
|
||||||
*/
|
*/
|
||||||
@ExceptionHandler(UserNotFoundException.class)
|
@ExceptionHandler(TooManyLoginAttemptsException.class)
|
||||||
public ResponseEntity<ErrorResponse> clientError(UserNotFoundException ex) {
|
public ResponseEntity<ErrorResponse> clientError(TooManyLoginAttemptsException ex) {
|
||||||
var body = new ErrorResponse("USER_NOT_FOUND", ex.getMessage());
|
var body = new ErrorResponse("TOO_MANY_ATTEMPTS", ex.getMessage());
|
||||||
return response(body, HttpStatus.NOT_ACCEPTABLE);
|
return ResponseEntity.status(HttpStatus.TOO_MANY_REQUESTS)
|
||||||
|
.header(HttpHeaders.RETRY_AFTER, String.valueOf(ex.getRetryAfterSeconds()))
|
||||||
|
.contentType(org.springframework.http.MediaType.APPLICATION_JSON)
|
||||||
|
.body(body);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ExceptionHandler(InvalidCredentialsException.class)
|
@ExceptionHandler(InvalidCredentialsException.class)
|
||||||
|
|||||||
@@ -0,0 +1,13 @@
|
|||||||
|
package auth_service.modules.login.exception;
|
||||||
|
|
||||||
|
import lombok.Getter;
|
||||||
|
|
||||||
|
@Getter
|
||||||
|
public class TooManyLoginAttemptsException extends RuntimeException {
|
||||||
|
private final long retryAfterSeconds;
|
||||||
|
|
||||||
|
public TooManyLoginAttemptsException(long retryAfterSeconds) {
|
||||||
|
super("Too many failed login attempts. Try again later.");
|
||||||
|
this.retryAfterSeconds = retryAfterSeconds;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,7 +0,0 @@
|
|||||||
package auth_service.modules.login.exception;
|
|
||||||
|
|
||||||
public class UserNotFoundException extends RuntimeException {
|
|
||||||
public UserNotFoundException(String message) {
|
|
||||||
super(message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -10,4 +10,5 @@ public class User {
|
|||||||
private String email;
|
private String email;
|
||||||
private String password;
|
private String password;
|
||||||
private Long steamId;
|
private Long steamId;
|
||||||
|
private boolean enabled;
|
||||||
}
|
}
|
||||||
@@ -16,6 +16,7 @@ public abstract class EntityMapper {
|
|||||||
model.setEmail(entity.getEmail());
|
model.setEmail(entity.getEmail());
|
||||||
model.setPassword(entity.getPassword());
|
model.setPassword(entity.getPassword());
|
||||||
model.setSteamId(entity.getSteamId());
|
model.setSteamId(entity.getSteamId());
|
||||||
|
model.setEnabled(entity.isEnabled());
|
||||||
}
|
}
|
||||||
|
|
||||||
return model;
|
return model;
|
||||||
|
|||||||
@@ -0,0 +1,62 @@
|
|||||||
|
package auth_service.modules.login.service;
|
||||||
|
|
||||||
|
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||||
|
import com.github.benmanes.caffeine.cache.Cache;
|
||||||
|
import com.github.benmanes.caffeine.cache.Caffeine;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.time.Duration;
|
||||||
|
import java.util.Locale;
|
||||||
|
import java.util.concurrent.atomic.AtomicInteger;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Counts failed logins per email and per client IP in memory. A key is blocked after too many
|
||||||
|
* failures until LOCK_DURATION has passed since its last failure. A restart resets all counters.
|
||||||
|
*/
|
||||||
|
@Service
|
||||||
|
public class LoginAttemptService {
|
||||||
|
|
||||||
|
static final int MAX_FAILURES_PER_EMAIL = 5;
|
||||||
|
static final int MAX_FAILURES_PER_IP = 20;
|
||||||
|
static final Duration LOCK_DURATION = Duration.ofMinutes(15);
|
||||||
|
|
||||||
|
private final Cache<String, AtomicInteger> failures = Caffeine.newBuilder()
|
||||||
|
.expireAfterWrite(LOCK_DURATION)
|
||||||
|
.maximumSize(100_000)
|
||||||
|
.build();
|
||||||
|
|
||||||
|
public void checkAllowed(String email, String clientIp) {
|
||||||
|
if (count(emailKey(email)) >= MAX_FAILURES_PER_EMAIL || count(ipKey(clientIp)) >= MAX_FAILURES_PER_IP) {
|
||||||
|
throw new TooManyLoginAttemptsException(LOCK_DURATION.toSeconds());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordFailure(String email, String clientIp) {
|
||||||
|
increment(emailKey(email));
|
||||||
|
increment(ipKey(clientIp));
|
||||||
|
}
|
||||||
|
|
||||||
|
public void recordSuccess(String email) {
|
||||||
|
failures.invalidate(emailKey(email));
|
||||||
|
}
|
||||||
|
|
||||||
|
private int count(String key) {
|
||||||
|
AtomicInteger counter = failures.getIfPresent(key);
|
||||||
|
return counter == null ? 0 : counter.get();
|
||||||
|
}
|
||||||
|
|
||||||
|
private void increment(String key) {
|
||||||
|
// put() again so expireAfterWrite restarts the lock window with every failure
|
||||||
|
AtomicInteger counter = failures.get(key, k -> new AtomicInteger());
|
||||||
|
counter.incrementAndGet();
|
||||||
|
failures.put(key, counter);
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String emailKey(String email) {
|
||||||
|
return "email:" + (email == null ? "" : email.trim().toLowerCase(Locale.ROOT));
|
||||||
|
}
|
||||||
|
|
||||||
|
private static String ipKey(String clientIp) {
|
||||||
|
return "ip:" + clientIp;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,35 +2,57 @@ package auth_service.modules.login.service;
|
|||||||
|
|
||||||
import auth_service.shared.security.TokenGenerator;
|
import auth_service.shared.security.TokenGenerator;
|
||||||
import auth_service.modules.login.exception.InvalidCredentialsException;
|
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||||
import auth_service.modules.login.exception.UserNotFoundException;
|
|
||||||
import auth_service.modules.login.model.User;
|
import auth_service.modules.login.model.User;
|
||||||
import auth_service.modules.login.persistence.MinimalUserRepository;
|
import auth_service.modules.login.persistence.MinimalUserRepository;
|
||||||
import lombok.RequiredArgsConstructor;
|
|
||||||
import lombok.extern.slf4j.Slf4j;
|
import lombok.extern.slf4j.Slf4j;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import java.util.UUID;
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
@RequiredArgsConstructor
|
|
||||||
@Slf4j
|
@Slf4j
|
||||||
public class LoginService {
|
public class LoginService {
|
||||||
private final MinimalUserRepository minimalUserRepository;
|
private final MinimalUserRepository minimalUserRepository;
|
||||||
private final PasswordEncoder passwordEncoder;
|
private final PasswordEncoder passwordEncoder;
|
||||||
private final TokenGenerator tokenGenerator;
|
private final TokenGenerator tokenGenerator;
|
||||||
|
private final LoginAttemptService loginAttemptService;
|
||||||
|
|
||||||
|
// Checked against for unknown emails, so they cost the same bcrypt time as a wrong password.
|
||||||
|
private final String dummyHash;
|
||||||
|
|
||||||
|
public LoginService(MinimalUserRepository minimalUserRepository, PasswordEncoder passwordEncoder,
|
||||||
|
TokenGenerator tokenGenerator, LoginAttemptService loginAttemptService) {
|
||||||
|
this.minimalUserRepository = minimalUserRepository;
|
||||||
|
this.passwordEncoder = passwordEncoder;
|
||||||
|
this.tokenGenerator = tokenGenerator;
|
||||||
|
this.loginAttemptService = loginAttemptService;
|
||||||
|
this.dummyHash = passwordEncoder.encode(UUID.randomUUID().toString());
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authenticates a user and returns a token.
|
* Authenticates a user and returns a token. Unknown email, wrong password and disabled
|
||||||
|
* account all fail with the same InvalidCredentialsException.
|
||||||
*
|
*
|
||||||
* @return The generated token
|
* @return The generated token
|
||||||
*/
|
*/
|
||||||
public String login(User userToLogin) {
|
public String login(User userToLogin, String clientIp) {
|
||||||
var foundUser = minimalUserRepository.findByEmail(userToLogin.getEmail())
|
String email = userToLogin.getEmail();
|
||||||
.orElseThrow(() -> new UserNotFoundException("User not found with given email"));
|
loginAttemptService.checkAllowed(email, clientIp);
|
||||||
var loginSuccessful = passwordEncoder.matches(userToLogin.getPassword(), foundUser.getPassword());
|
|
||||||
if (!loginSuccessful) {
|
Optional<User> foundUser = minimalUserRepository.findByEmail(email);
|
||||||
throw new InvalidCredentialsException("Invalid credentials");
|
String hash = foundUser.map(User::getPassword).orElse(dummyHash);
|
||||||
|
boolean passwordMatches = passwordEncoder.matches(userToLogin.getPassword(), hash);
|
||||||
|
|
||||||
|
if (foundUser.isEmpty() || !passwordMatches || !foundUser.get().isEnabled()) {
|
||||||
|
loginAttemptService.recordFailure(email, clientIp);
|
||||||
|
throw new InvalidCredentialsException("Invalid email or password");
|
||||||
}
|
}
|
||||||
minimalUserRepository.updateLoginDate(foundUser.getId());
|
|
||||||
return tokenGenerator.generateToken(String.valueOf(foundUser.getId()), foundUser.getEmail(), foundUser.getSteamId());
|
User user = foundUser.get();
|
||||||
|
loginAttemptService.recordSuccess(email);
|
||||||
|
minimalUserRepository.updateLoginDate(user.getId());
|
||||||
|
return tokenGenerator.generateToken(String.valueOf(user.getId()), user.getEmail(), user.getSteamId());
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -3,6 +3,7 @@ package auth_service.modules.login.api;
|
|||||||
import auth_service.configuration.security.JwtAuthenticationFilter;
|
import auth_service.configuration.security.JwtAuthenticationFilter;
|
||||||
import auth_service.modules.login.api.dto.request.LoginRequest;
|
import auth_service.modules.login.api.dto.request.LoginRequest;
|
||||||
import auth_service.modules.login.exception.InvalidCredentialsException;
|
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||||
|
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||||
import auth_service.modules.login.model.User;
|
import auth_service.modules.login.model.User;
|
||||||
import auth_service.modules.login.service.LoginService;
|
import auth_service.modules.login.service.LoginService;
|
||||||
import auth_service.shared.test.AbstractWebMvcTest;
|
import auth_service.shared.test.AbstractWebMvcTest;
|
||||||
@@ -17,8 +18,11 @@ import org.springframework.test.context.bean.override.mockito.MockitoBean;
|
|||||||
import org.springframework.test.web.servlet.MockMvc;
|
import org.springframework.test.web.servlet.MockMvc;
|
||||||
|
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post;
|
||||||
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.header;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath;
|
||||||
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status;
|
||||||
|
|
||||||
@@ -46,7 +50,7 @@ class LoginControllerTest extends AbstractWebMvcTest {
|
|||||||
void login_Success() throws Exception {
|
void login_Success() throws Exception {
|
||||||
// Arrange
|
// Arrange
|
||||||
var request = new LoginRequest("test@example.com", "password");
|
var request = new LoginRequest("test@example.com", "password");
|
||||||
when(loginService.login(any(User.class))).thenReturn("mock-token");
|
when(loginService.login(any(User.class), eq("127.0.0.1"))).thenReturn("mock-token");
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
@@ -61,12 +65,53 @@ class LoginControllerTest extends AbstractWebMvcTest {
|
|||||||
void login_Failure() throws Exception {
|
void login_Failure() throws Exception {
|
||||||
// Arrange
|
// Arrange
|
||||||
var request = new LoginRequest("test@example.com", "wrong-password");
|
var request = new LoginRequest("test@example.com", "wrong-password");
|
||||||
when(loginService.login(any(User.class))).thenThrow(new InvalidCredentialsException("Invalid credentials"));
|
when(loginService.login(any(User.class), any())).thenThrow(new InvalidCredentialsException("Invalid email or password"));
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
mockMvc.perform(post("/api/auth/login")
|
mockMvc.perform(post("/api/auth/login")
|
||||||
.contentType(MediaType.APPLICATION_JSON)
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
.content(loginRequestJson.write(request).getJson()))
|
.content(loginRequestJson.write(request).getJson()))
|
||||||
.andExpect(status().isUnauthorized());
|
.andExpect(status().isUnauthorized())
|
||||||
|
.andExpect(jsonPath("$.code").value("INVALID_CREDENTIALS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_UsesClientIpAddedByTraefik() throws Exception {
|
||||||
|
// Arrange
|
||||||
|
var request = new LoginRequest("test@example.com", "password");
|
||||||
|
when(loginService.login(any(User.class), eq("203.0.113.7"))).thenReturn("mock-token");
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.header("X-Forwarded-For", "10.0.0.1, 203.0.113.7")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(loginRequestJson.write(request).getJson()))
|
||||||
|
.andExpect(status().isOk());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_TooManyAttempts() throws Exception {
|
||||||
|
// Arrange
|
||||||
|
var request = new LoginRequest("test@example.com", "password");
|
||||||
|
when(loginService.login(any(User.class), any())).thenThrow(new TooManyLoginAttemptsException(900));
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content(loginRequestJson.write(request).getJson()))
|
||||||
|
.andExpect(status().isTooManyRequests())
|
||||||
|
.andExpect(header().string("Retry-After", "900"))
|
||||||
|
.andExpect(jsonPath("$.code").value("TOO_MANY_ATTEMPTS"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_MissingPassword() throws Exception {
|
||||||
|
mockMvc.perform(post("/api/auth/login")
|
||||||
|
.contentType(MediaType.APPLICATION_JSON)
|
||||||
|
.content("""
|
||||||
|
{"email":"test@example.com"}"""))
|
||||||
|
.andExpect(status().isBadRequest());
|
||||||
|
|
||||||
|
verifyNoInteractions(loginService);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
package auth_service.modules.login.service;
|
||||||
|
|
||||||
|
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||||
|
import auth_service.shared.test.AbstractUnitTest;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
class LoginAttemptServiceTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
private LoginAttemptService attempts;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
attempts = new LoginAttemptService();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blocksEmailAfterFiveFailures() {
|
||||||
|
for (int i = 0; i < LoginAttemptService.MAX_FAILURES_PER_EMAIL; i++) {
|
||||||
|
attempts.checkAllowed("a@b.de", "1.1.1." + i);
|
||||||
|
attempts.recordFailure("a@b.de", "1.1.1." + i);
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThrows(TooManyLoginAttemptsException.class, () -> attempts.checkAllowed("a@b.de", "9.9.9.9"));
|
||||||
|
assertThrows(TooManyLoginAttemptsException.class, () -> attempts.checkAllowed("A@B.DE", "9.9.9.9"));
|
||||||
|
assertDoesNotThrow(() -> attempts.checkAllowed("other@b.de", "9.9.9.9"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void blocksIpAfterTwentyFailures() {
|
||||||
|
for (int i = 0; i < LoginAttemptService.MAX_FAILURES_PER_IP; i++) {
|
||||||
|
attempts.recordFailure("user" + i + "@b.de", "1.2.3.4");
|
||||||
|
}
|
||||||
|
|
||||||
|
assertThrows(TooManyLoginAttemptsException.class, () -> attempts.checkAllowed("new@b.de", "1.2.3.4"));
|
||||||
|
assertDoesNotThrow(() -> attempts.checkAllowed("new@b.de", "5.6.7.8"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void successResetsEmailCounter() {
|
||||||
|
for (int i = 0; i < LoginAttemptService.MAX_FAILURES_PER_EMAIL - 1; i++) {
|
||||||
|
attempts.recordFailure("a@b.de", "1.1.1.1");
|
||||||
|
}
|
||||||
|
attempts.recordSuccess("a@b.de");
|
||||||
|
attempts.recordFailure("a@b.de", "1.1.1.1");
|
||||||
|
|
||||||
|
assertDoesNotThrow(() -> attempts.checkAllowed("a@b.de", "1.1.1.1"));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -2,13 +2,12 @@ package auth_service.modules.login.service;
|
|||||||
|
|
||||||
import auth_service.shared.security.TokenGenerator;
|
import auth_service.shared.security.TokenGenerator;
|
||||||
import auth_service.modules.login.exception.InvalidCredentialsException;
|
import auth_service.modules.login.exception.InvalidCredentialsException;
|
||||||
import auth_service.modules.login.exception.UserNotFoundException;
|
import auth_service.modules.login.exception.TooManyLoginAttemptsException;
|
||||||
import auth_service.modules.login.model.User;
|
import auth_service.modules.login.model.User;
|
||||||
import auth_service.modules.login.persistence.MinimalUserRepository;
|
import auth_service.modules.login.persistence.MinimalUserRepository;
|
||||||
import auth_service.shared.test.AbstractUnitTest;
|
import auth_service.shared.test.AbstractUnitTest;
|
||||||
import org.junit.jupiter.api.BeforeEach;
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.mockito.InjectMocks;
|
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
|
||||||
@@ -16,6 +15,14 @@ import java.util.Optional;
|
|||||||
|
|
||||||
import static org.junit.jupiter.api.Assertions.assertEquals;
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
import static org.junit.jupiter.api.Assertions.assertThrows;
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.doThrow;
|
||||||
|
import static org.mockito.Mockito.lenient;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.verifyNoInteractions;
|
||||||
import static org.mockito.Mockito.when;
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -23,6 +30,8 @@ import static org.mockito.Mockito.when;
|
|||||||
*/
|
*/
|
||||||
class LoginServiceTest extends AbstractUnitTest {
|
class LoginServiceTest extends AbstractUnitTest {
|
||||||
|
|
||||||
|
private static final String IP = "203.0.113.7";
|
||||||
|
|
||||||
@Mock
|
@Mock
|
||||||
private MinimalUserRepository minimalUserRepository;
|
private MinimalUserRepository minimalUserRepository;
|
||||||
|
|
||||||
@@ -32,13 +41,17 @@ class LoginServiceTest extends AbstractUnitTest {
|
|||||||
@Mock
|
@Mock
|
||||||
private TokenGenerator tokenGenerator;
|
private TokenGenerator tokenGenerator;
|
||||||
|
|
||||||
@InjectMocks
|
@Mock
|
||||||
|
private LoginAttemptService loginAttemptService;
|
||||||
|
|
||||||
private LoginService loginService;
|
private LoginService loginService;
|
||||||
|
|
||||||
private User user;
|
private User user;
|
||||||
|
|
||||||
@BeforeEach
|
@BeforeEach
|
||||||
void setUp() {
|
void setUp() {
|
||||||
|
lenient().when(passwordEncoder.encode(anyString())).thenReturn("dummy-hash");
|
||||||
|
loginService = new LoginService(minimalUserRepository, passwordEncoder, tokenGenerator, loginAttemptService);
|
||||||
user = User.builder()
|
user = User.builder()
|
||||||
.email("test@example.com")
|
.email("test@example.com")
|
||||||
.password("password")
|
.password("password")
|
||||||
@@ -52,38 +65,73 @@ class LoginServiceTest extends AbstractUnitTest {
|
|||||||
.id(815L)
|
.id(815L)
|
||||||
.email("test@example.com")
|
.email("test@example.com")
|
||||||
.password("encodedPassword")
|
.password("encodedPassword")
|
||||||
|
.enabled(true)
|
||||||
.build();
|
.build();
|
||||||
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
|
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
|
||||||
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
|
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
|
||||||
when(tokenGenerator.generateToken("815", "test@example.com", null)).thenReturn("mock-token");
|
when(tokenGenerator.generateToken("815", "test@example.com", null)).thenReturn("mock-token");
|
||||||
|
|
||||||
// Act
|
// Act
|
||||||
var result = loginService.login(user);
|
var result = loginService.login(user, IP);
|
||||||
|
|
||||||
// Assert
|
// Assert
|
||||||
assertEquals("mock-token", result);
|
assertEquals("mock-token", result);
|
||||||
|
verify(loginAttemptService).recordSuccess("test@example.com");
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void login_UserNotFound() {
|
void login_UnknownEmail_SameErrorAsWrongPassword() {
|
||||||
// Arrange
|
// Arrange
|
||||||
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.empty());
|
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
assertThrows(UserNotFoundException.class, () -> loginService.login(user));
|
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP));
|
||||||
|
// bcrypt still runs, so the response time does not reveal that the email is unknown
|
||||||
|
verify(passwordEncoder).matches("password", "dummy-hash");
|
||||||
|
verify(loginAttemptService).recordFailure("test@example.com", IP);
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
void login_InvalidCredentials() {
|
void login_WrongPassword() {
|
||||||
// Arrange
|
// Arrange
|
||||||
var dbUser = User.builder()
|
var dbUser = User.builder()
|
||||||
.email("test@example.com")
|
.email("test@example.com")
|
||||||
.password("encodedPassword")
|
.password("encodedPassword")
|
||||||
|
.enabled(true)
|
||||||
.build();
|
.build();
|
||||||
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
|
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
|
||||||
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(false);
|
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(false);
|
||||||
|
|
||||||
// Act & Assert
|
// Act & Assert
|
||||||
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user));
|
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP));
|
||||||
|
verify(loginAttemptService).recordFailure("test@example.com", IP);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_DisabledUser() {
|
||||||
|
// Arrange
|
||||||
|
var dbUser = User.builder()
|
||||||
|
.id(815L)
|
||||||
|
.email("test@example.com")
|
||||||
|
.password("encodedPassword")
|
||||||
|
.enabled(false)
|
||||||
|
.build();
|
||||||
|
when(minimalUserRepository.findByEmail(user.getEmail())).thenReturn(Optional.of(dbUser));
|
||||||
|
when(passwordEncoder.matches(user.getPassword(), dbUser.getPassword())).thenReturn(true);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
assertThrows(InvalidCredentialsException.class, () -> loginService.login(user, IP));
|
||||||
|
verify(tokenGenerator, never()).generateToken(any(), any(), any());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void login_TooManyAttempts() {
|
||||||
|
// Arrange
|
||||||
|
doThrow(new TooManyLoginAttemptsException(900)).when(loginAttemptService).checkAllowed("test@example.com", IP);
|
||||||
|
|
||||||
|
// Act & Assert
|
||||||
|
assertThrows(TooManyLoginAttemptsException.class, () -> loginService.login(user, IP));
|
||||||
|
verifyNoInteractions(minimalUserRepository);
|
||||||
|
verify(passwordEncoder, never()).matches(any(), eq("encodedPassword"));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Reference in new issue
Block a user