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:
parent
8e495672d3
commit
3d4a6daee9
9 changed files with 198 additions and 1 deletions
|
|
@ -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));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {}
|
||||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -0,0 +1,7 @@
|
|||
package se.bilhalsning.exception;
|
||||
|
||||
public class InvalidCredentialsException extends RuntimeException {
|
||||
public InvalidCredentialsException() {
|
||||
super("Felaktig e-post eller lösenord");
|
||||
}
|
||||
}
|
||||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue