feat: implement JWT authentication — service, filter, SecurityFilterChain
This commit is contained in:
parent
0d9baeb6e5
commit
ce95a451ce
11 changed files with 741 additions and 0 deletions
|
|
@ -1,15 +1,46 @@
|
||||||
package se.bilhalsning.config;
|
package se.bilhalsning.config;
|
||||||
|
|
||||||
|
import org.springframework.beans.factory.annotation.Value;
|
||||||
import org.springframework.context.annotation.Bean;
|
import org.springframework.context.annotation.Bean;
|
||||||
import org.springframework.context.annotation.Configuration;
|
import org.springframework.context.annotation.Configuration;
|
||||||
|
import org.springframework.security.config.annotation.web.builders.HttpSecurity;
|
||||||
|
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity;
|
||||||
|
import org.springframework.security.config.http.SessionCreationPolicy;
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder;
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter;
|
||||||
|
import se.bilhalsning.security.JwtAuthenticationFilter;
|
||||||
|
import se.bilhalsning.security.JwtService;
|
||||||
|
|
||||||
@Configuration
|
@Configuration
|
||||||
|
@EnableWebSecurity
|
||||||
public class SecurityConfig {
|
public class SecurityConfig {
|
||||||
|
|
||||||
@Bean
|
@Bean
|
||||||
public PasswordEncoder passwordEncoder() {
|
public PasswordEncoder passwordEncoder() {
|
||||||
return new BCryptPasswordEncoder();
|
return new BCryptPasswordEncoder();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public JwtService jwtService(@Value("${app.jwt.secret}") String secret) {
|
||||||
|
return new JwtService(secret);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Bean
|
||||||
|
public SecurityFilterChain securityFilterChain(HttpSecurity http,
|
||||||
|
JwtAuthenticationFilter jwtAuthenticationFilter) throws Exception {
|
||||||
|
http
|
||||||
|
.csrf(csrf -> csrf.disable())
|
||||||
|
.sessionManagement(sm -> sm.sessionCreationPolicy(SessionCreationPolicy.STATELESS))
|
||||||
|
.authorizeHttpRequests(auth -> auth
|
||||||
|
.requestMatchers("/api/auth/register", "/api/auth/login").permitAll()
|
||||||
|
.requestMatchers("/api/webhooks/**").permitAll()
|
||||||
|
.requestMatchers("/api/vehicles/**").permitAll()
|
||||||
|
.requestMatchers("/api/templates").permitAll()
|
||||||
|
.anyRequest().authenticated())
|
||||||
|
.addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class);
|
||||||
|
|
||||||
|
return http.build();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,50 @@
|
||||||
|
package se.bilhalsning.security;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.authentication.UsernamePasswordAuthenticationToken;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.web.authentication.WebAuthenticationDetailsSource;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
@Component
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class JwtAuthenticationFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
private final JwtService jwtService;
|
||||||
|
private final UserDetailsServiceImpl userDetailsService;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response,
|
||||||
|
FilterChain filterChain) throws ServletException, IOException {
|
||||||
|
String authHeader = request.getHeader("Authorization");
|
||||||
|
|
||||||
|
if (authHeader == null || !authHeader.startsWith("Bearer ")) {
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
String token = authHeader.substring(7);
|
||||||
|
String username = jwtService.extractUsername(token);
|
||||||
|
|
||||||
|
if (username != null && SecurityContextHolder.getContext().getAuthentication() == null) {
|
||||||
|
var userDetails = userDetailsService.loadUserByUsername(username);
|
||||||
|
|
||||||
|
if (jwtService.isTokenValid(token)) {
|
||||||
|
var authToken = new UsernamePasswordAuthenticationToken(
|
||||||
|
userDetails, null, userDetails.getAuthorities());
|
||||||
|
authToken.setDetails(new WebAuthenticationDetailsSource().buildDetails(request));
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(authToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
filterChain.doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,57 @@
|
||||||
|
package se.bilhalsning.security;
|
||||||
|
|
||||||
|
import io.jsonwebtoken.ExpiredJwtException;
|
||||||
|
import io.jsonwebtoken.Jwts;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
import java.util.Date;
|
||||||
|
import javax.crypto.SecretKey;
|
||||||
|
import javax.crypto.spec.SecretKeySpec;
|
||||||
|
|
||||||
|
public class JwtService {
|
||||||
|
|
||||||
|
private static final long DEFAULT_EXPIRATION_MS = 86_400_000;
|
||||||
|
|
||||||
|
private final SecretKey secretKey;
|
||||||
|
private final long expirationMs;
|
||||||
|
|
||||||
|
public JwtService(String secret) {
|
||||||
|
this(secret, DEFAULT_EXPIRATION_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
JwtService(String secret, long expirationMs) {
|
||||||
|
this.secretKey = new SecretKeySpec(secret.getBytes(StandardCharsets.UTF_8), "HmacSHA256");
|
||||||
|
this.expirationMs = expirationMs;
|
||||||
|
}
|
||||||
|
|
||||||
|
public String generateToken(String email) {
|
||||||
|
return Jwts.builder()
|
||||||
|
.subject(email)
|
||||||
|
.issuedAt(new Date())
|
||||||
|
.expiration(new Date(System.currentTimeMillis() + expirationMs))
|
||||||
|
.signWith(secretKey)
|
||||||
|
.compact();
|
||||||
|
}
|
||||||
|
|
||||||
|
public String extractUsername(String token) {
|
||||||
|
return Jwts.parser()
|
||||||
|
.verifyWith(secretKey)
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token)
|
||||||
|
.getPayload()
|
||||||
|
.getSubject();
|
||||||
|
}
|
||||||
|
|
||||||
|
public boolean isTokenValid(String token) {
|
||||||
|
try {
|
||||||
|
Jwts.parser()
|
||||||
|
.verifyWith(secretKey)
|
||||||
|
.build()
|
||||||
|
.parseSignedClaims(token);
|
||||||
|
return true;
|
||||||
|
} catch (ExpiredJwtException e) {
|
||||||
|
return false;
|
||||||
|
} catch (Exception e) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,25 @@
|
||||||
|
package se.bilhalsning.security;
|
||||||
|
|
||||||
|
import java.util.List;
|
||||||
|
import lombok.RequiredArgsConstructor;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetailsService;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import org.springframework.stereotype.Service;
|
||||||
|
import se.bilhalsning.repository.UserRepository;
|
||||||
|
|
||||||
|
@Service
|
||||||
|
@RequiredArgsConstructor
|
||||||
|
public class UserDetailsServiceImpl implements UserDetailsService {
|
||||||
|
|
||||||
|
private final UserRepository userRepository;
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public UserDetails loadUserByUsername(String email) throws UsernameNotFoundException {
|
||||||
|
String normalizedEmail = email.toLowerCase().trim();
|
||||||
|
return userRepository.findByEmail(normalizedEmail)
|
||||||
|
.map(user -> new User(user.getEmail(), user.getPasswordHash(), List.of()))
|
||||||
|
.orElseThrow(() -> new UsernameNotFoundException("User not found: " + normalizedEmail));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,81 @@
|
||||||
|
package se.bilhalsning;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.dao.DataIntegrityViolationException;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import se.bilhalsning.entity.Subscription;
|
||||||
|
import se.bilhalsning.entity.User;
|
||||||
|
import se.bilhalsning.repository.UserRepository;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Transactional
|
||||||
|
class FlywayMigrationTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldPersistAndRetrieveUser() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("test@example.com");
|
||||||
|
user.setPasswordHash("hash");
|
||||||
|
user.setSubscription(Subscription.BASIC);
|
||||||
|
userRepository.saveAndFlush(user);
|
||||||
|
|
||||||
|
User found = userRepository.findById(user.getId()).orElseThrow();
|
||||||
|
|
||||||
|
assertEquals(user.getId(), found.getId());
|
||||||
|
assertEquals("test@example.com", found.getEmail());
|
||||||
|
assertEquals("hash", found.getPasswordHash());
|
||||||
|
assertEquals(Subscription.BASIC, found.getSubscription());
|
||||||
|
assertNotNull(found.getCreatedAt());
|
||||||
|
assertNotNull(found.getUpdatedAt());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldEnforceUniqueEmail() {
|
||||||
|
User first = new User();
|
||||||
|
first.setEmail("unique@example.com");
|
||||||
|
first.setPasswordHash("hash");
|
||||||
|
userRepository.saveAndFlush(first);
|
||||||
|
|
||||||
|
User duplicate = new User();
|
||||||
|
duplicate.setEmail("unique@example.com");
|
||||||
|
duplicate.setPasswordHash("hash");
|
||||||
|
|
||||||
|
assertThrows(DataIntegrityViolationException.class, () -> {
|
||||||
|
userRepository.saveAndFlush(duplicate);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldPersistAllSubscriptionValues() {
|
||||||
|
for (Subscription sub : Subscription.values()) {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail(sub.getValue() + "@example.com");
|
||||||
|
user.setPasswordHash("hash");
|
||||||
|
user.setSubscription(sub);
|
||||||
|
userRepository.saveAndFlush(user);
|
||||||
|
|
||||||
|
User found = userRepository.findByEmail(sub.getValue() + "@example.com").orElseThrow();
|
||||||
|
assertEquals(sub, found.getSubscription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldDefaultSubscriptionToNone() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("default@example.com");
|
||||||
|
user.setPasswordHash("hash");
|
||||||
|
userRepository.saveAndFlush(user);
|
||||||
|
|
||||||
|
User found = userRepository.findByEmail("default@example.com").orElseThrow();
|
||||||
|
assertEquals(Subscription.NONE, found.getSubscription());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,44 @@
|
||||||
|
package se.bilhalsning.config;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertDoesNotThrow;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertInstanceOf;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder;
|
||||||
|
import org.springframework.security.crypto.password.PasswordEncoder;
|
||||||
|
import org.springframework.security.web.SecurityFilterChain;
|
||||||
|
import se.bilhalsning.security.JwtService;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
class SecurityConfigTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private SecurityFilterChain securityFilterChain;
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private JwtService jwtService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldProvideBcryptPasswordEncoder() {
|
||||||
|
assertNotNull(passwordEncoder);
|
||||||
|
assertInstanceOf(BCryptPasswordEncoder.class, passwordEncoder);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldProvideSecurityFilterChain() {
|
||||||
|
assertNotNull(securityFilterChain);
|
||||||
|
assertDoesNotThrow(() -> securityFilterChain.getFilters());
|
||||||
|
assertNotNull(securityFilterChain.getFilters());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldExposeJwtServiceAsBean() {
|
||||||
|
assertNotNull(jwtService);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,59 @@
|
||||||
|
package se.bilhalsning.repository;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.springframework.beans.factory.annotation.Autowired;
|
||||||
|
import org.springframework.boot.test.context.SpringBootTest;
|
||||||
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import se.bilhalsning.entity.User;
|
||||||
|
|
||||||
|
@SpringBootTest
|
||||||
|
@Transactional
|
||||||
|
class UserRepositoryTest {
|
||||||
|
|
||||||
|
@Autowired
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldFindByEmailWhenExists() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("find@example.com");
|
||||||
|
user.setPasswordHash("hash");
|
||||||
|
userRepository.saveAndFlush(user);
|
||||||
|
|
||||||
|
Optional<User> result = userRepository.findByEmail("find@example.com");
|
||||||
|
|
||||||
|
assertTrue(result.isPresent());
|
||||||
|
assertEquals("find@example.com", result.get().getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnEmptyWhenEmailNotFound() {
|
||||||
|
Optional<User> result = userRepository.findByEmail("no@example.com");
|
||||||
|
|
||||||
|
assertFalse(result.isPresent());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnTrueWhenEmailExists() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("exists@example.com");
|
||||||
|
user.setPasswordHash("hash");
|
||||||
|
userRepository.saveAndFlush(user);
|
||||||
|
|
||||||
|
boolean result = userRepository.existsByEmail("exists@example.com");
|
||||||
|
|
||||||
|
assertTrue(result);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnFalseWhenEmailDoesNotExist() {
|
||||||
|
boolean result = userRepository.existsByEmail("nope@example.com");
|
||||||
|
|
||||||
|
assertFalse(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,138 @@
|
||||||
|
package se.bilhalsning.security;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNull;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.util.List;
|
||||||
|
import org.junit.jupiter.api.AfterEach;
|
||||||
|
import org.junit.jupiter.api.BeforeEach;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.mock.web.MockHttpServletRequest;
|
||||||
|
import org.springframework.mock.web.MockHttpServletResponse;
|
||||||
|
import org.springframework.security.core.Authentication;
|
||||||
|
import org.springframework.security.core.context.SecurityContextHolder;
|
||||||
|
import org.springframework.security.core.userdetails.User;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class JwtAuthenticationFilterTest {
|
||||||
|
|
||||||
|
private static final String VALID_TOKEN = "valid.jwt.token";
|
||||||
|
private static final String EMAIL = "test@example.com";
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private JwtService jwtService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserDetailsServiceImpl userDetailsService;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private FilterChain filterChain;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private JwtAuthenticationFilter filter;
|
||||||
|
|
||||||
|
@BeforeEach
|
||||||
|
void setUp() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
@AfterEach
|
||||||
|
void tearDown() {
|
||||||
|
SecurityContextHolder.clearContext();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldSetAuthenticationWhenValidToken() throws ServletException, IOException {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.addHeader("Authorization", "Bearer " + VALID_TOKEN);
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
UserDetails userDetails = new User(EMAIL, "password", List.of());
|
||||||
|
when(jwtService.extractUsername(VALID_TOKEN)).thenReturn(EMAIL);
|
||||||
|
when(jwtService.isTokenValid(VALID_TOKEN)).thenReturn(true);
|
||||||
|
when(userDetailsService.loadUserByUsername(EMAIL)).thenReturn(userDetails);
|
||||||
|
|
||||||
|
filter.doFilterInternal(request, response, filterChain);
|
||||||
|
|
||||||
|
Authentication auth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
assertNotNull(auth);
|
||||||
|
assertEquals(EMAIL, auth.getName());
|
||||||
|
verify(filterChain).doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldNotSetAuthenticationWhenNoAuthHeader() throws ServletException, IOException {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
filter.doFilterInternal(request, response, filterChain);
|
||||||
|
|
||||||
|
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
verify(filterChain).doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldNotSetAuthenticationWhenHeaderLacksBearerPrefix() throws ServletException, IOException {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.addHeader("Authorization", "Basic dXNlcjpwYXNz");
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
filter.doFilterInternal(request, response, filterChain);
|
||||||
|
|
||||||
|
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
verify(jwtService, never()).extractUsername(anyString());
|
||||||
|
verify(filterChain).doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldNotSetAuthenticationWhenTokenInvalid() throws ServletException, IOException {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.addHeader("Authorization", "Bearer invalid-token");
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
when(jwtService.extractUsername("invalid-token")).thenReturn(EMAIL);
|
||||||
|
when(jwtService.isTokenValid("invalid-token")).thenReturn(false);
|
||||||
|
|
||||||
|
filter.doFilterInternal(request, response, filterChain);
|
||||||
|
|
||||||
|
assertNull(SecurityContextHolder.getContext().getAuthentication());
|
||||||
|
verify(filterChain).doFilter(request, response);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldContinueChainWhenAuthAlreadyPresent() throws ServletException, IOException {
|
||||||
|
MockHttpServletRequest request = new MockHttpServletRequest();
|
||||||
|
request.addHeader("Authorization", "Bearer " + VALID_TOKEN);
|
||||||
|
MockHttpServletResponse response = new MockHttpServletResponse();
|
||||||
|
|
||||||
|
Authentication existingAuth = SecurityContextHolder.getContext().getAuthentication();
|
||||||
|
if (existingAuth == null) {
|
||||||
|
// Pre-populate auth context to simulate existing auth
|
||||||
|
UserDetails userDetails = new User("existing@example.com", "password", List.of());
|
||||||
|
SecurityContextHolder.getContext().setAuthentication(
|
||||||
|
new org.springframework.security.authentication.UsernamePasswordAuthenticationToken(
|
||||||
|
userDetails, null, userDetails.getAuthorities()));
|
||||||
|
}
|
||||||
|
|
||||||
|
when(jwtService.extractUsername(VALID_TOKEN)).thenReturn(EMAIL);
|
||||||
|
|
||||||
|
filter.doFilterInternal(request, response, filterChain);
|
||||||
|
|
||||||
|
// Should have skipped JWT processing, existing auth should remain
|
||||||
|
verify(jwtService, never()).isTokenValid(anyString());
|
||||||
|
verify(filterChain).doFilter(request, response);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,74 @@
|
||||||
|
package se.bilhalsning.security;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
|
||||||
|
class JwtServiceTest {
|
||||||
|
|
||||||
|
private static final String SECRET = "this-is-a-test-secret-that-is-at-least-32-bytes-long!!";
|
||||||
|
private static final String EMAIL = "test@example.com";
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldGenerateValidTokenThatCanBeParsed() {
|
||||||
|
JwtService jwtService = new JwtService(SECRET);
|
||||||
|
|
||||||
|
String token = jwtService.generateToken(EMAIL);
|
||||||
|
|
||||||
|
assertNotNull(token);
|
||||||
|
String extracted = jwtService.extractUsername(token);
|
||||||
|
assertEquals(EMAIL, extracted);
|
||||||
|
assertTrue(jwtService.isTokenValid(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldExtractUsername() {
|
||||||
|
JwtService jwtService = new JwtService(SECRET);
|
||||||
|
|
||||||
|
String token = jwtService.generateToken("another@example.com");
|
||||||
|
String username = jwtService.extractUsername(token);
|
||||||
|
|
||||||
|
assertEquals("another@example.com", username);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnFalseForExpiredToken() {
|
||||||
|
JwtService jwtService = new JwtService(SECRET, 0);
|
||||||
|
|
||||||
|
String token = jwtService.generateToken(EMAIL);
|
||||||
|
|
||||||
|
assertFalse(jwtService.isTokenValid(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnFalseForMalformedToken() {
|
||||||
|
JwtService jwtService = new JwtService(SECRET);
|
||||||
|
|
||||||
|
assertFalse(jwtService.isTokenValid("not-a-valid-token"));
|
||||||
|
assertFalse(jwtService.isTokenValid(""));
|
||||||
|
assertFalse(jwtService.isTokenValid("header.payload.signature"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnFalseForTokenSignedWithDifferentKey() {
|
||||||
|
JwtService first = new JwtService(SECRET);
|
||||||
|
JwtService second = new JwtService("another-secret-that-is-at-least-32-bytes-long");
|
||||||
|
|
||||||
|
String token = first.generateToken(EMAIL);
|
||||||
|
|
||||||
|
assertFalse(second.isTokenValid(token));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnFalseForTamperedToken() {
|
||||||
|
JwtService jwtService = new JwtService(SECRET);
|
||||||
|
|
||||||
|
String token = jwtService.generateToken(EMAIL);
|
||||||
|
String tampered = token.substring(0, token.length() - 3) + "xyz";
|
||||||
|
|
||||||
|
assertFalse(jwtService.isTokenValid(tampered));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,61 @@
|
||||||
|
package se.bilhalsning.security;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.mockito.ArgumentMatchers.anyString;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
import org.springframework.security.core.userdetails.UserDetails;
|
||||||
|
import org.springframework.security.core.userdetails.UsernameNotFoundException;
|
||||||
|
import se.bilhalsning.entity.User;
|
||||||
|
import se.bilhalsning.repository.UserRepository;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class UserDetailsServiceImplTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private UserDetailsServiceImpl userDetailsService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldReturnUserDetailsWhenUserExists() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("test@example.com");
|
||||||
|
user.setPasswordHash("hashed-password");
|
||||||
|
when(userRepository.findByEmail("test@example.com")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
UserDetails result = userDetailsService.loadUserByUsername("test@example.com");
|
||||||
|
|
||||||
|
assertEquals("test@example.com", result.getUsername());
|
||||||
|
assertEquals("hashed-password", result.getPassword());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldThrowUsernameNotFoundExceptionWhenUserMissing() {
|
||||||
|
when(userRepository.findByEmail(anyString())).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
assertThrows(UsernameNotFoundException.class, () ->
|
||||||
|
userDetailsService.loadUserByUsername("missing@example.com"));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldNormalizeEmailToLowercase() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("uppercase@example.com");
|
||||||
|
user.setPasswordHash("hashed-password");
|
||||||
|
when(userRepository.findByEmail("uppercase@example.com")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
userDetailsService.loadUserByUsername("UPPERCASE@EXAMPLE.COM");
|
||||||
|
|
||||||
|
verify(userRepository).findByEmail("uppercase@example.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,121 @@
|
||||||
|
package se.bilhalsning.service;
|
||||||
|
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertEquals;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertFalse;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertNotNull;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertThrows;
|
||||||
|
import static org.junit.jupiter.api.Assertions.assertTrue;
|
||||||
|
import static org.mockito.ArgumentMatchers.any;
|
||||||
|
import static org.mockito.ArgumentMatchers.eq;
|
||||||
|
import static org.mockito.Mockito.never;
|
||||||
|
import static org.mockito.Mockito.verify;
|
||||||
|
import static org.mockito.Mockito.when;
|
||||||
|
|
||||||
|
import java.util.Optional;
|
||||||
|
import org.junit.jupiter.api.Test;
|
||||||
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
|
import org.mockito.ArgumentCaptor;
|
||||||
|
import org.mockito.InjectMocks;
|
||||||
|
import org.mockito.Mock;
|
||||||
|
import org.mockito.junit.jupiter.MockitoExtension;
|
||||||
|
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.repository.UserRepository;
|
||||||
|
|
||||||
|
@ExtendWith(MockitoExtension.class)
|
||||||
|
class UserServiceTest {
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private UserRepository userRepository;
|
||||||
|
|
||||||
|
@Mock
|
||||||
|
private PasswordEncoder passwordEncoder;
|
||||||
|
|
||||||
|
@InjectMocks
|
||||||
|
private UserService userService;
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldCreateUserWhenEmailIsNew() {
|
||||||
|
when(userRepository.existsByEmail("new@example.com")).thenReturn(false);
|
||||||
|
when(passwordEncoder.encode("password123")).thenReturn("hashed");
|
||||||
|
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
User result = userService.createUser("new@example.com", "password123");
|
||||||
|
|
||||||
|
assertNotNull(result);
|
||||||
|
assertEquals("new@example.com", result.getEmail());
|
||||||
|
assertEquals("hashed", result.getPasswordHash());
|
||||||
|
assertEquals(Subscription.NONE, result.getSubscription());
|
||||||
|
verify(userRepository).save(any(User.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldThrowWhenEmailAlreadyExists() {
|
||||||
|
when(userRepository.existsByEmail("taken@example.com")).thenReturn(true);
|
||||||
|
|
||||||
|
assertThrows(EmailAlreadyExistsException.class, () ->
|
||||||
|
userService.createUser("taken@example.com", "password123"));
|
||||||
|
|
||||||
|
verify(userRepository, never()).save(any(User.class));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldNormalizeEmailToLowercase() {
|
||||||
|
when(userRepository.existsByEmail("user@example.com")).thenReturn(false);
|
||||||
|
when(passwordEncoder.encode(any())).thenReturn("hashed");
|
||||||
|
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
User result = userService.createUser("User@Example.COM", "password123");
|
||||||
|
|
||||||
|
assertEquals("user@example.com", result.getEmail());
|
||||||
|
verify(userRepository).existsByEmail("user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldTrimWhitespaceFromEmail() {
|
||||||
|
when(userRepository.existsByEmail("user@example.com")).thenReturn(false);
|
||||||
|
when(passwordEncoder.encode(any())).thenReturn("hashed");
|
||||||
|
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
User result = userService.createUser(" user@example.com ", "password123");
|
||||||
|
|
||||||
|
assertEquals("user@example.com", result.getEmail());
|
||||||
|
verify(userRepository).existsByEmail("user@example.com");
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldHashPasswordBeforeSave() {
|
||||||
|
when(userRepository.existsByEmail("test@example.com")).thenReturn(false);
|
||||||
|
when(passwordEncoder.encode("myPassword")).thenReturn("bcryptHash");
|
||||||
|
when(userRepository.save(any(User.class))).thenAnswer(inv -> inv.getArgument(0));
|
||||||
|
|
||||||
|
userService.createUser("test@example.com", "myPassword");
|
||||||
|
|
||||||
|
ArgumentCaptor<User> captor = ArgumentCaptor.forClass(User.class);
|
||||||
|
verify(userRepository).save(captor.capture());
|
||||||
|
assertEquals("bcryptHash", captor.getValue().getPasswordHash());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldFindByEmailWhenExists() {
|
||||||
|
User user = new User();
|
||||||
|
user.setEmail("found@example.com");
|
||||||
|
when(userRepository.findByEmail("found@example.com")).thenReturn(Optional.of(user));
|
||||||
|
|
||||||
|
Optional<User> result = userService.findByEmail("found@example.com");
|
||||||
|
|
||||||
|
assertTrue(result.isPresent());
|
||||||
|
assertEquals("found@example.com", result.get().getEmail());
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
void shouldFindByEmailNormalizesInput() {
|
||||||
|
when(userRepository.findByEmail("user@example.com")).thenReturn(Optional.empty());
|
||||||
|
|
||||||
|
userService.findByEmail(" User@Example.COM ");
|
||||||
|
|
||||||
|
verify(userRepository).findByEmail("user@example.com");
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue