From 3d4a6daee98cea970a83b66842ab3723078118dd Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Joakim=20M=C3=B6rling?= Date: Wed, 13 May 2026 19:16:19 +0200 Subject: [PATCH] feat: add login endpoint with JWT authentication MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add POST /api/auth/login endpoint that authenticates users by email and password, returning a JWT token on success. Also fixes a critical bug where expired or malformed JWT tokens in the Authorization header caused unhandled exceptions, crashing requests to all endpoints including public ones like registration. Changes: - Add AuthController.login() endpoint with LoginRequest DTO - Add UserService.authenticate() that validates credentials and throws InvalidCredentialsException on failure - Add InvalidCredentialsException and GlobalExceptionHandler handler that maps it to 401 with Swedish error message - Fix JwtAuthenticationFilter to catch JwtException (expired, malformed) and pass through without crashing — the filter now acts as a graceful enricher rather than a gatekeeper - Add 5 controller tests for login endpoint (success, 401, validation) - Add 4 service tests for authenticate() (success, email not found, password mismatch, email normalization) - Add 2 filter tests for expired and malformed token pass-through --- .../controller/AuthController.java | 9 +++ .../java/se/bilhalsning/dto/LoginRequest.java | 9 +++ .../exception/GlobalExceptionHandler.java | 7 +++ .../InvalidCredentialsException.java | 7 +++ .../security/JwtAuthenticationFilter.java | 9 ++- .../se/bilhalsning/service/UserService.java | 11 ++++ .../controller/AuthControllerTest.java | 58 +++++++++++++++++++ .../security/JwtAuthenticationFilterTest.java | 38 ++++++++++++ .../bilhalsning/service/UserServiceTest.java | 51 ++++++++++++++++ 9 files changed, 198 insertions(+), 1 deletion(-) create mode 100644 backend/src/main/java/se/bilhalsning/dto/LoginRequest.java create mode 100644 backend/src/main/java/se/bilhalsning/exception/InvalidCredentialsException.java diff --git a/backend/src/main/java/se/bilhalsning/controller/AuthController.java b/backend/src/main/java/se/bilhalsning/controller/AuthController.java index b56aea3..273ade2 100644 --- a/backend/src/main/java/se/bilhalsning/controller/AuthController.java +++ b/backend/src/main/java/se/bilhalsning/controller/AuthController.java @@ -9,7 +9,9 @@ import org.springframework.web.bind.annotation.RequestBody; import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RestController; import se.bilhalsning.dto.AuthResponse; +import se.bilhalsning.dto.LoginRequest; import se.bilhalsning.dto.RegisterRequest; +import se.bilhalsning.entity.User; import se.bilhalsning.security.JwtService; import se.bilhalsning.service.UserService; @@ -27,4 +29,11 @@ public class AuthController { String token = jwtService.generateToken(request.email().toLowerCase().trim()); return ResponseEntity.status(HttpStatus.CREATED).body(new AuthResponse(token)); } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request) { + User user = userService.authenticate(request.email(), request.password()); + String token = jwtService.generateToken(user.getEmail()); + return ResponseEntity.ok(new AuthResponse(token)); + } } diff --git a/backend/src/main/java/se/bilhalsning/dto/LoginRequest.java b/backend/src/main/java/se/bilhalsning/dto/LoginRequest.java new file mode 100644 index 0000000..430e94d --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/dto/LoginRequest.java @@ -0,0 +1,9 @@ +package se.bilhalsning.dto; + +import jakarta.validation.constraints.Email; +import jakarta.validation.constraints.NotBlank; + +public record LoginRequest( + @NotBlank @Email String email, + @NotBlank String password +) {} diff --git a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java index 8643a3f..a73132a 100644 --- a/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java +++ b/backend/src/main/java/se/bilhalsning/exception/GlobalExceptionHandler.java @@ -14,6 +14,13 @@ public class GlobalExceptionHandler { private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class); + @ExceptionHandler(InvalidCredentialsException.class) + public ResponseEntity handleInvalidCredentials(InvalidCredentialsException ex) { + return ResponseEntity + .status(HttpStatus.UNAUTHORIZED) + .body(new ErrorResponse(ex.getMessage())); + } + @ExceptionHandler(EmailAlreadyExistsException.class) public ResponseEntity handleEmailAlreadyExists(EmailAlreadyExistsException ex) { return ResponseEntity diff --git a/backend/src/main/java/se/bilhalsning/exception/InvalidCredentialsException.java b/backend/src/main/java/se/bilhalsning/exception/InvalidCredentialsException.java new file mode 100644 index 0000000..c1f8230 --- /dev/null +++ b/backend/src/main/java/se/bilhalsning/exception/InvalidCredentialsException.java @@ -0,0 +1,7 @@ +package se.bilhalsning.exception; + +public class InvalidCredentialsException extends RuntimeException { + public InvalidCredentialsException() { + super("Felaktig e-post eller lösenord"); + } +} diff --git a/backend/src/main/java/se/bilhalsning/security/JwtAuthenticationFilter.java b/backend/src/main/java/se/bilhalsning/security/JwtAuthenticationFilter.java index 09e8b4a..fc3cc84 100644 --- a/backend/src/main/java/se/bilhalsning/security/JwtAuthenticationFilter.java +++ b/backend/src/main/java/se/bilhalsning/security/JwtAuthenticationFilter.java @@ -4,6 +4,7 @@ import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import jakarta.servlet.http.HttpServletRequest; import jakarta.servlet.http.HttpServletResponse; +import io.jsonwebtoken.JwtException; import java.io.IOException; import java.util.List; import lombok.RequiredArgsConstructor; @@ -32,7 +33,13 @@ public class JwtAuthenticationFilter extends OncePerRequestFilter { } String token = authHeader.substring(7); - String username = jwtService.extractUsername(token); + String username; + try { + username = jwtService.extractUsername(token); + } catch (JwtException e) { + filterChain.doFilter(request, response); + return; + } if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) { var userDetails = userDetailsService.loadUserByUsername(username); diff --git a/backend/src/main/java/se/bilhalsning/service/UserService.java b/backend/src/main/java/se/bilhalsning/service/UserService.java index bb625a8..103a746 100644 --- a/backend/src/main/java/se/bilhalsning/service/UserService.java +++ b/backend/src/main/java/se/bilhalsning/service/UserService.java @@ -6,6 +6,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import org.springframework.stereotype.Service; import se.bilhalsning.entity.User; import se.bilhalsning.exception.EmailAlreadyExistsException; +import se.bilhalsning.exception.InvalidCredentialsException; import se.bilhalsning.repository.UserRepository; @Service @@ -29,4 +30,14 @@ public class UserService { user.setPasswordHash(passwordEncoder.encode(password)); return userRepository.save(user); } + + public User authenticate(String email, String password) { + String normalizedEmail = email.toLowerCase().trim(); + User user = userRepository.findByEmail(normalizedEmail) + .orElseThrow(InvalidCredentialsException::new); + if (!passwordEncoder.matches(password, user.getPasswordHash())) { + throw new InvalidCredentialsException(); + } + return user; + } } diff --git a/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java b/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java index a3a0678..7204f84 100644 --- a/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java +++ b/backend/src/test/java/se/bilhalsning/controller/AuthControllerTest.java @@ -13,8 +13,11 @@ import org.springframework.boot.webmvc.test.autoconfigure.AutoConfigureMockMvc; import org.springframework.http.MediaType; import org.springframework.test.context.bean.override.mockito.MockitoBean; import org.springframework.test.web.servlet.MockMvc; +import se.bilhalsning.dto.LoginRequest; import se.bilhalsning.dto.RegisterRequest; +import se.bilhalsning.entity.User; import se.bilhalsning.exception.EmailAlreadyExistsException; +import se.bilhalsning.exception.InvalidCredentialsException; import se.bilhalsning.security.JwtService; import se.bilhalsning.service.UserService; @@ -85,4 +88,59 @@ class AuthControllerTest { .content(objectMapper.writeValueAsString(request))) .andExpect(status().isBadRequest()); } + + @Test + void shouldReturn200AndTokenWhenLoginSucceeds() throws Exception { + User user = new User(); + user.setEmail("user@example.com"); + when(userService.authenticate("user@example.com", "password123")).thenReturn(user); + when(jwtService.generateToken("user@example.com")).thenReturn("login-jwt-token"); + + LoginRequest request = new LoginRequest("user@example.com", "password123"); + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").value("login-jwt-token")); + } + + @Test + void shouldReturn401WhenCredentialsAreInvalid() throws Exception { + when(userService.authenticate("user@example.com", "wrongpassword")) + .thenThrow(new InvalidCredentialsException()); + + LoginRequest request = new LoginRequest("user@example.com", "wrongpassword"); + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isUnauthorized()) + .andExpect(jsonPath("$.message").value("Felaktig e-post eller lösenord")); + } + + @Test + void shouldReturn400WhenLoginEmailIsBlank() throws Exception { + LoginRequest request = new LoginRequest("", "password123"); + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenLoginPasswordIsBlank() throws Exception { + LoginRequest request = new LoginRequest("user@example.com", ""); + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } + + @Test + void shouldReturn400WhenLoginEmailIsInvalid() throws Exception { + LoginRequest request = new LoginRequest("not-an-email", "password123"); + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isBadRequest()); + } } diff --git a/backend/src/test/java/se/bilhalsning/security/JwtAuthenticationFilterTest.java b/backend/src/test/java/se/bilhalsning/security/JwtAuthenticationFilterTest.java index 297baad..fe9d8b1 100644 --- a/backend/src/test/java/se/bilhalsning/security/JwtAuthenticationFilterTest.java +++ b/backend/src/test/java/se/bilhalsning/security/JwtAuthenticationFilterTest.java @@ -8,6 +8,8 @@ import static org.mockito.Mockito.never; import static org.mockito.Mockito.verify; import static org.mockito.Mockito.when; +import io.jsonwebtoken.ExpiredJwtException; +import io.jsonwebtoken.MalformedJwtException; import jakarta.servlet.FilterChain; import jakarta.servlet.ServletException; import java.io.IOException; @@ -135,4 +137,40 @@ class JwtAuthenticationFilterTest { verify(jwtService, never()).isTokenValid(anyString()); verify(filterChain).doFilter(request, response); } + + @Test + void shouldPassThroughWhenTokenExpired() throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer expired.token"); + request.setRequestURI("/api/auth/register"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + when(jwtService.extractUsername("expired.token")) + .thenThrow(new ExpiredJwtException(null, null, "Token expired")); + + filter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(jwtService, never()).isTokenValid(anyString()); + verify(userDetailsService, never()).loadUserByUsername(anyString()); + verify(filterChain).doFilter(request, response); + } + + @Test + void shouldPassThroughWhenTokenMalformed() throws ServletException, IOException { + MockHttpServletRequest request = new MockHttpServletRequest(); + request.addHeader("Authorization", "Bearer not.a.jwt"); + request.setRequestURI("/api/auth/login"); + MockHttpServletResponse response = new MockHttpServletResponse(); + + when(jwtService.extractUsername("not.a.jwt")) + .thenThrow(new MalformedJwtException("Invalid JWT")); + + filter.doFilterInternal(request, response, filterChain); + + assertNull(SecurityContextHolder.getContext().getAuthentication()); + verify(jwtService, never()).isTokenValid(anyString()); + verify(userDetailsService, never()).loadUserByUsername(anyString()); + verify(filterChain).doFilter(request, response); + } } diff --git a/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java b/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java index 77f72b5..784de1e 100644 --- a/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java +++ b/backend/src/test/java/se/bilhalsning/service/UserServiceTest.java @@ -22,6 +22,7 @@ import org.springframework.security.crypto.password.PasswordEncoder; import se.bilhalsning.entity.Subscription; import se.bilhalsning.entity.User; import se.bilhalsning.exception.EmailAlreadyExistsException; +import se.bilhalsning.exception.InvalidCredentialsException; import se.bilhalsning.repository.UserRepository; @ExtendWith(MockitoExtension.class) @@ -118,4 +119,54 @@ class UserServiceTest { verify(userRepository).findByEmail("user@example.com"); } + + @Test + void shouldReturnUserWhenCredentialsAreValid() { + User user = new User(); + user.setEmail("user@example.com"); + user.setPasswordHash("hashed"); + + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("password123", "hashed")).thenReturn(true); + + User result = userService.authenticate("user@example.com", "password123"); + + assertNotNull(result); + assertEquals("user@example.com", result.getEmail()); + } + + @Test + void shouldThrowWhenEmailNotFoundOnAuthenticate() { + when(userRepository.findByEmail("unknown@example.com")).thenReturn(Optional.empty()); + + assertThrows(InvalidCredentialsException.class, () -> + userService.authenticate("unknown@example.com", "password123")); + } + + @Test + void shouldThrowWhenPasswordDoesNotMatch() { + User user = new User(); + user.setEmail("user@example.com"); + user.setPasswordHash("hashed"); + + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("wrongpassword", "hashed")).thenReturn(false); + + assertThrows(InvalidCredentialsException.class, () -> + userService.authenticate("user@example.com", "wrongpassword")); + } + + @Test + void shouldNormalizeEmailBeforeAuthenticating() { + User user = new User(); + user.setEmail("user@example.com"); + user.setPasswordHash("hashed"); + + when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.of(user)); + when(passwordEncoder.matches("password123", "hashed")).thenReturn(true); + + userService.authenticate(" User@Example.COM ", "password123"); + + verify(userRepository).findByEmail("user@example.com"); + } }