feat: add login endpoint with JWT authentication

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
This commit is contained in:
Joakim Mörling 2026-05-13 19:16:19 +02:00
parent 8e495672d3
commit 3d4a6daee9
9 changed files with 198 additions and 1 deletions

View file

@ -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<AuthResponse> 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));
}
}

View file

@ -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
) {}

View file

@ -14,6 +14,13 @@ public class GlobalExceptionHandler {
private static final Logger log = LoggerFactory.getLogger(GlobalExceptionHandler.class);
@ExceptionHandler(InvalidCredentialsException.class)
public ResponseEntity<ErrorResponse> handleInvalidCredentials(InvalidCredentialsException ex) {
return ResponseEntity
.status(HttpStatus.UNAUTHORIZED)
.body(new ErrorResponse(ex.getMessage()));
}
@ExceptionHandler(EmailAlreadyExistsException.class)
public ResponseEntity<ErrorResponse> handleEmailAlreadyExists(EmailAlreadyExistsException ex) {
return ResponseEntity

View file

@ -0,0 +1,7 @@
package se.bilhalsning.exception;
public class InvalidCredentialsException extends RuntimeException {
public InvalidCredentialsException() {
super("Felaktig e-post eller lösenord");
}
}

View file

@ -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);

View file

@ -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;
}
}

View file

@ -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());
}
}

View file

@ -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);
}
}

View file

@ -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");
}
}