SecurityConfig.kt

package org.openapitools.security

import org.openapitools.repository.TokenBlocklistRepository
import org.openapitools.repository.UserRepository
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.MediaType
import org.springframework.security.authentication.AuthenticationManager
import org.springframework.security.config.annotation.authentication.configuration.AuthenticationConfiguration
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.userdetails.User
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
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
class SecurityConfig(
	private val userRepository: UserRepository,
	private val jwtUtils: JwtUtils,
	private val tokenBlocklist: TokenBlocklistRepository,
) {
	// BCrypt is the standard algorithm for hashing passwords
	@Bean
	fun passwordEncoder(): PasswordEncoder = BCryptPasswordEncoder()

	// Tells Spring how to load a user by username — called during login to verify credentials
	@Bean
	fun userDetailsService(): UserDetailsService =
		UserDetailsService { username ->
			val user =
				userRepository
					.findByUsername(username)
					.orElseThrow { UsernameNotFoundException("User not found: $username") }
			User
				.withUsername(user.username)
				.password(user.password)
				.roles("USER")
				.build()
		}

	// The AuthenticationManager is what processes a login attempt (username + password check)
	@Bean
	fun authenticationManager(config: AuthenticationConfiguration): AuthenticationManager = config.authenticationManager

	@Bean
	fun filterChain(http: HttpSecurity): SecurityFilterChain {
		http
			.csrf { it.disable() } // not needed for stateless JWT APIs
			// Picks up the CorsConfigurationSource bean so preflights are answered
			// before the auth filter — without this, OPTIONS to protected endpoints
			// returns 403 and the browser reports a CORS error.
			.cors { }
			.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) }
			.authorizeHttpRequests { auth ->
				auth
					.requestMatchers(
						"/api/v1/users/register",
						"/api/v1/users/login",
						"/h2-console/**",
						"/swagger-ui/**",
						"/v3/api-docs/**",
						"/actuator/health/**",
						"/actuator/prometheus",
					).permitAll()
					.anyRequest()
					.authenticated() // all other routes require a valid JWT
			}.exceptionHandling { ex ->
				ex.authenticationEntryPoint { _, response, _ ->
					response.contentType = MediaType.APPLICATION_JSON_VALUE
					response.status = 401
					response.writer.write("""{"message":"Missing or invalid token"}""")
				}
				ex.accessDeniedHandler { _, response, _ ->
					response.contentType = MediaType.APPLICATION_JSON_VALUE
					response.status = 403
					response.writer.write("""{"message":"Access denied"}""")
				}
			}.headers { it.frameOptions { fo -> fo.disable() } } // needed for H2 console iframe
			.addFilterBefore(
				JwtAuthFilter(jwtUtils, userRepository, tokenBlocklist),
				UsernamePasswordAuthenticationFilter::class.java,
			)
		return http.build()
	}
}