diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/AuthController.java b/src/main/java/net/siegeln/cameleer/saas/auth/AuthController.java new file mode 100644 index 0000000..432daf1 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/auth/AuthController.java @@ -0,0 +1,54 @@ +package net.siegeln.cameleer.saas.auth; + +import jakarta.servlet.http.HttpServletRequest; +import jakarta.validation.Valid; +import net.siegeln.cameleer.saas.auth.dto.AuthResponse; +import net.siegeln.cameleer.saas.auth.dto.LoginRequest; +import net.siegeln.cameleer.saas.auth.dto.RegisterRequest; +import org.springframework.http.HttpStatus; +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.PostMapping; +import org.springframework.web.bind.annotation.RequestBody; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +@RestController +@RequestMapping("/api/auth") +public class AuthController { + + private final AuthService authService; + + public AuthController(AuthService authService) { + this.authService = authService; + } + + @PostMapping("/register") + public ResponseEntity register(@Valid @RequestBody RegisterRequest request, + HttpServletRequest httpRequest) { + try { + var response = authService.register(request, extractClientIp(httpRequest)); + return ResponseEntity.status(HttpStatus.CREATED).body(response); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.CONFLICT).build(); + } + } + + @PostMapping("/login") + public ResponseEntity login(@Valid @RequestBody LoginRequest request, + HttpServletRequest httpRequest) { + try { + var response = authService.login(request, extractClientIp(httpRequest)); + return ResponseEntity.ok(response); + } catch (IllegalArgumentException e) { + return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build(); + } + } + + private String extractClientIp(HttpServletRequest request) { + String xForwardedFor = request.getHeader("X-Forwarded-For"); + if (xForwardedFor != null && !xForwardedFor.isEmpty()) { + return xForwardedFor.split(",")[0].trim(); + } + return request.getRemoteAddr(); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java b/src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java new file mode 100644 index 0000000..d4c9e7e --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/auth/JwtAuthenticationFilter.java @@ -0,0 +1,60 @@ +package net.siegeln.cameleer.saas.auth; + +import jakarta.servlet.FilterChain; +import jakarta.servlet.ServletException; +import jakarta.servlet.http.HttpServletRequest; +import jakarta.servlet.http.HttpServletResponse; +import org.springframework.security.authentication.UsernamePasswordAuthenticationToken; +import org.springframework.security.core.authority.SimpleGrantedAuthority; +import org.springframework.security.core.context.SecurityContextHolder; +import org.springframework.security.web.authentication.WebAuthenticationDetailsSource; +import org.springframework.stereotype.Component; +import org.springframework.web.filter.OncePerRequestFilter; + +import java.io.IOException; + +@Component +public class JwtAuthenticationFilter extends OncePerRequestFilter { + + private final JwtService jwtService; + + public JwtAuthenticationFilter(JwtService jwtService) { + this.jwtService = jwtService; + } + + @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); + + if (!jwtService.isTokenValid(token)) { + filterChain.doFilter(request, response); + return; + } + + String email = jwtService.extractEmail(token); + var userId = jwtService.extractUserId(token); + var roles = jwtService.extractRoles(token); + + var authorities = roles.stream() + .map(role -> new SimpleGrantedAuthority("ROLE_" + role)) + .toList(); + + var authentication = new UsernamePasswordAuthenticationToken( + email, userId, authorities + ); + authentication.setDetails(new WebAuthenticationDetailsSource().buildDetails(request)); + + SecurityContextHolder.getContext().setAuthentication(authentication); + + filterChain.doFilter(request, response); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/config/HealthController.java b/src/main/java/net/siegeln/cameleer/saas/config/HealthController.java new file mode 100644 index 0000000..f02bcdb --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/config/HealthController.java @@ -0,0 +1,18 @@ +package net.siegeln.cameleer.saas.config; + +import org.springframework.http.ResponseEntity; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RestController; + +import java.util.Map; + +@RestController +@RequestMapping("/api/health") +public class HealthController { + + @GetMapping("/secured") + public ResponseEntity> secured() { + return ResponseEntity.ok(Map.of("status", "authenticated")); + } +} diff --git a/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java new file mode 100644 index 0000000..2d61c22 --- /dev/null +++ b/src/main/java/net/siegeln/cameleer/saas/config/SecurityConfig.java @@ -0,0 +1,45 @@ +package net.siegeln.cameleer.saas.config; + +import net.siegeln.cameleer.saas.auth.JwtAuthenticationFilter; +import org.springframework.context.annotation.Bean; +import org.springframework.context.annotation.Configuration; +import org.springframework.security.config.annotation.method.configuration.EnableMethodSecurity; +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.password.PasswordEncoder; +import org.springframework.security.web.SecurityFilterChain; +import org.springframework.security.web.authentication.UsernamePasswordAuthenticationFilter; + +@Configuration +@EnableWebSecurity +@EnableMethodSecurity +public class SecurityConfig { + + private final JwtAuthenticationFilter jwtAuthenticationFilter; + + public SecurityConfig(JwtAuthenticationFilter jwtAuthenticationFilter) { + this.jwtAuthenticationFilter = jwtAuthenticationFilter; + } + + @Bean + public SecurityFilterChain securityFilterChain(HttpSecurity http) throws Exception { + http + .csrf(csrf -> csrf.disable()) + .sessionManagement(session -> session.sessionCreationPolicy(SessionCreationPolicy.STATELESS)) + .authorizeHttpRequests(auth -> auth + .requestMatchers("/api/auth/**").permitAll() + .requestMatchers("/actuator/health").permitAll() + .anyRequest().authenticated() + ) + .addFilterBefore(jwtAuthenticationFilter, UsernamePasswordAuthenticationFilter.class); + + return http.build(); + } + + @Bean + public PasswordEncoder passwordEncoder() { + return new BCryptPasswordEncoder(); + } +} diff --git a/src/test/java/net/siegeln/cameleer/saas/auth/AuthControllerTest.java b/src/test/java/net/siegeln/cameleer/saas/auth/AuthControllerTest.java new file mode 100644 index 0000000..d874094 --- /dev/null +++ b/src/test/java/net/siegeln/cameleer/saas/auth/AuthControllerTest.java @@ -0,0 +1,125 @@ +package net.siegeln.cameleer.saas.auth; + +import com.fasterxml.jackson.databind.ObjectMapper; +import net.siegeln.cameleer.saas.TestcontainersConfig; +import net.siegeln.cameleer.saas.auth.dto.LoginRequest; +import net.siegeln.cameleer.saas.auth.dto.RegisterRequest; +import org.junit.jupiter.api.Test; +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.boot.test.autoconfigure.web.servlet.AutoConfigureMockMvc; +import org.springframework.boot.test.context.SpringBootTest; +import org.springframework.context.annotation.Import; +import org.springframework.http.MediaType; +import org.springframework.test.context.ActiveProfiles; +import org.springframework.test.web.servlet.MockMvc; + +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.get; +import static org.springframework.test.web.servlet.request.MockMvcRequestBuilders.post; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.jsonPath; +import static org.springframework.test.web.servlet.result.MockMvcResultMatchers.status; + +@SpringBootTest +@AutoConfigureMockMvc +@Import(TestcontainersConfig.class) +@ActiveProfiles("test") +class AuthControllerTest { + + @Autowired + private MockMvc mockMvc; + + @Autowired + private ObjectMapper objectMapper; + + @Test + void register_returns201WithToken() throws Exception { + var request = new RegisterRequest("newuser@example.com", "New User", "password123"); + + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()) + .andExpect(jsonPath("$.token").isNotEmpty()) + .andExpect(jsonPath("$.email").value("newuser@example.com")) + .andExpect(jsonPath("$.name").value("New User")); + } + + @Test + void register_returns409ForDuplicateEmail() throws Exception { + var request = new RegisterRequest("duplicate@example.com", "User One", "password123"); + + // First registration + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isCreated()); + + // Duplicate registration + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(request))) + .andExpect(status().isConflict()); + } + + @Test + void login_returns200WithToken() throws Exception { + var registerRequest = new RegisterRequest("loginuser@example.com", "Login User", "password123"); + + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequest))) + .andExpect(status().isCreated()); + + var loginRequest = new LoginRequest("loginuser@example.com", "password123"); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.token").isNotEmpty()) + .andExpect(jsonPath("$.email").value("loginuser@example.com")); + } + + @Test + void login_returns401ForBadPassword() throws Exception { + var registerRequest = new RegisterRequest("badpass@example.com", "Bad Pass", "password123"); + + mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequest))) + .andExpect(status().isCreated()); + + var loginRequest = new LoginRequest("badpass@example.com", "wrong-password"); + + mockMvc.perform(post("/api/auth/login") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(loginRequest))) + .andExpect(status().isUnauthorized()); + } + + @Test + void protectedEndpoint_returns401WithoutToken() throws Exception { + mockMvc.perform(get("/api/health/secured")) + .andExpect(status().isUnauthorized()); + } + + @Test + void protectedEndpoint_returns200WithValidToken() throws Exception { + // Register to get a token + var registerRequest = new RegisterRequest("secured@example.com", "Secured User", "password123"); + + var result = mockMvc.perform(post("/api/auth/register") + .contentType(MediaType.APPLICATION_JSON) + .content(objectMapper.writeValueAsString(registerRequest))) + .andExpect(status().isCreated()) + .andReturn(); + + var responseBody = objectMapper.readTree(result.getResponse().getContentAsString()); + String token = responseBody.get("token").asText(); + + // Access protected endpoint with token + mockMvc.perform(get("/api/health/secured") + .header("Authorization", "Bearer " + token)) + .andExpect(status().isOk()) + .andExpect(jsonPath("$.status").value("authenticated")); + } +}