Spring Security adalah framework keamanan yang dapat dikonfigurasi secara mendalam. Framework ini memakai pola filter chain: setiap request HTTP melewati serangkaian filter yang memeriksa autentikasi, otorisasi, dan transformasi. Pada chapter ini kita akan memahami arsitektur filter chain, JWT auth filter, SecurityContext berbasis ThreadLocal, dan anotasi @PreAuthorize untuk otorisasi method-level.

Filter Chain #

Saat request HTTP masuk ke aplikasi Spring, request tersebut melewati FilterChainProxy — koleksi filter yang berurutan. Filter pertama mungkin SecurityContextPersistenceFilter (memuat konteks dari session), lalu UsernamePasswordAuthenticationFilter (memproses form login), lalu JwtAuthenticationFilter (memverifikasi JWT), dan seterusnya.

text Filter chain
HTTP Request
    |
    v
[SecurityContextPersistenceFilter]  -> muat konteks dari session
    |
    v
[JwtAuthenticationFilter]            -> verifikasi Bearer token
    |
    v
[AuthorizationFilter]               -> cek URL + role
    |
    v
[DispatcherServlet]                 -> handler Spring MVC

Filter dapat menghentikan chain dengan mengembalikan respons langsung (mis. 401 Unauthorized). Bila tidak ada filter yang menolak, request mencapai DispatcherServlet dan handler Spring MVC.

JwtAuthenticationFilter #

JWT auth filter adalah filter kustom yang membaca header Authorization: Bearer <token>, memverifikasi signature JWT, dan mengisi SecurityContext dengan principal. Implementasi lazim:

kotlin JwtAuthenticationFilter
class JwtAuthenticationFilter : OncePerRequestFilter() {
    override fun doFilterInternal(req, res, chain) {
        val token = extractToken(req)
        if (token != null && jwtUtil.validate(token)) {
            val principal = jwtUtil.parse(token)
            SecurityContextHolder.setContext(SecurityContext(principal))
        }
        chain.doFilter(req, res)
    }
}

Filter hanya mengisi SecurityContext bila token valid. Bila tidak valid atau tidak ada, filter tidak menolak — filter otorisasi di akhir chain yang menentukan apakah request diizinkan.

SecurityContext #

SecurityContext menyimpan principal thread-locally. Setelah JWT filter mengisi context, handler dapat mengaksesnya via SecurityContextHolder.getContext().authentication. ThreadLocal memastikan setiap request memiliki context sendiri — tidak ada race condition antar request.

kotlin SecurityContext access
val auth = SecurityContextHolder.getContext().authentication
val principal = auth.principal as UserPrincipal
println("Request dari user: ${principal.nama}")

@PreAuthorize dan @Secured #

Spring Security menyediakan anotasi method-level untuk otorisasi. @PreAuthorize("hasRole('ADMIN')") memeriksa apakah principal saat ini memiliki role ADMIN sebelum method dijalankan.

kotlin @PreAuthorize
@PreAuthorize("hasRole('ADMIN')")
fun deleteUser(id: Int) { ... }

@PreAuthorize("hasAnyRole('USER','ADMIN')")
fun getProfile(): UserProfile { ... }

@PreAuthorize("#userId == authentication.principal.id")
fun getUser(@PathVariable userId: Int): User { ... }

Ekspresi SpEL #userId == authentication.principal.id memungkinkan otorisasi berbasis parameter — user hanya bisa melihat profilnya sendiri.

Demo Filter Chain #

Kita akan memodelkan Spring Security sebagai class JwtFilter, SecurityContext (ThreadLocal), dan SecuredController dengan anotasi @PreAuthorize dan @Secured. Program ini mendemonstrasikan enam skenario request.

kotlin
// spring_security.kt
// Mendemonstrasikan Spring Security: filter chain, JWT auth filter,
// method-level security (@PreAuthorize). Pada kode produksi, Spring Security
// memproses anotasi dan filter. Di sini kita memodelkannya sebagai filter
// chain sederhana yang mengevaluasi JWT dan mengizinkan/menolak request.

// Mock anotasi Spring Security
@Target(AnnotationTarget.FUNCTION) annotation class PreAuthorize(val value: String)
@Target(AnnotationTarget.FUNCTION) annotation class Secured(vararg val roles: String)
@Target(AnnotationTarget.CLASS) annotation class EnableMethodSecurity
@Target(AnnotationTarget.CLASS) annotation class EnableWebSecurity

data class UserPrincipal(val id: Int, val nama: String, val roles: Set<String>)
data class HttpRequest(
    val method: String,
    val path: String,
    val headers: Map<String, String> = emptyMap()
)
data class HttpResponse(val status: Int, val body: String)

// JwtFilter: mengekstrak token dari header Authorization
class JwtFilter {
    // Token format demo: "JWT.<userId>.<nama>.<roles_csv>"
    fun extract(req: HttpRequest): UserPrincipal? {
        val authHeader = req.headers["Authorization"] ?: return null
        if (!authHeader.startsWith("Bearer ")) return null
        val token = authHeader.removePrefix("Bearer ").trim()
        return decode(token)
    }

    private fun decode(token: String): UserPrincipal? {
        if (!token.startsWith("JWT.")) return null
        val parts = token.removePrefix("JWT.").split(".")
        if (parts.size != 3) return null
        val id = parts[0].toIntOrNull() ?: return null
        val nama = parts[1]
        val roles = parts[2].split(",").toSet()
        return UserPrincipal(id, nama, roles)
    }
}

// SecurityContext: menyimpan principal thread-local (di Spring: ThreadLocal)
object SecurityContext {
    private val holder = ThreadLocal<UserPrincipal?>()
    fun set(principal: UserPrincipal?) { holder.set(principal) }
    fun get(): UserPrincipal? = holder.get()
    fun clear() { holder.remove() }
}

// Filter chain sederhana: JwtFilter -> AuthorizationFilter -> handler
class FilterChain(private val filters: List<(HttpRequest) -> HttpResponse?>) {
    fun process(req: HttpRequest): HttpResponse {
        for (f in filters) {
            val decision = f(req)
            if (decision != null) return decision
        }
        return HttpResponse(200, "OK")
    }
}

class SecuredController {
    // @PreAuthorize("hasRole('ADMIN')")
    @PreAuthorize("hasRole('ADMIN')")
    fun deleteUser(req: HttpRequest, principal: UserPrincipal): HttpResponse =
        HttpResponse(200, """{"deleted":true,"by":"${principal.nama}"}""")

    // @PreAuthorize("hasAnyRole('USER','ADMIN')")
    @PreAuthorize("hasAnyRole('USER','ADMIN')")
    fun getProfile(principal: UserPrincipal): HttpResponse =
        HttpResponse(200, """{"id":${principal.id},"nama":"${principal.nama}"}""")

    // @Secured("ADMIN")
    @Secured("ADMIN")
    fun resetStats(): HttpResponse =
        HttpResponse(200, """{"reset":true}""")

    // No security: public endpoint
    fun health(): HttpResponse = HttpResponse(200, """{"status":"UP"}""")
}

fun main() {
    val jwt = JwtFilter()
    val controller = SecuredController()

    println("=== Request 1: GET /health (public) ===")
    val req1 = HttpRequest("GET", "/health")
    println("Status: 200")
    println("Body: ${controller.health().body}")

    println("\n=== Request 2: GET /profile (tanpa Authorization) ===")
    val req2 = HttpRequest("GET", "/profile")
    val principal2 = jwt.extract(req2)
    if (principal2 == null) {
        println("Status: 401")
        println("""Body: {"error":"Unauthorized"}""")
    }

    println("\n=== Request 3: GET /profile (token role=USER) ===")
    val req3 = HttpRequest("GET", "/profile",
        headers = mapOf("Authorization" to "Bearer JWT.1.Budi.USER"))
    val principal3 = jwt.extract(req3)
    if (principal3 != null) {
        SecurityContext.set(principal3)
        val allowed = principal3.roles.contains("USER") || principal3.roles.contains("ADMIN")
        if (allowed) {
            val resp = controller.getProfile(principal3)
            println("Status: ${resp.status}")
            println("Body: ${resp.body}")
        } else {
            println("Status: 403")
            println("""Body: {"error":"Forbidden"}""")
        }
        SecurityContext.clear()
    }

    println("\n=== Request 4: DELETE /users/5 (token role=USER, butuh ADMIN) ===")
    val req4 = HttpRequest("DELETE", "/users/5",
        headers = mapOf("Authorization" to "Bearer JWT.1.Budi.USER"))
    val principal4 = jwt.extract(req4)
    val allowed4 = principal4?.roles?.contains("ADMIN") == true
    if (!allowed4) {
        println("Status: 403")
        println("""Body: {"error":"Forbidden: butuh role ADMIN"}""")
    }

    println("\n=== Request 5: DELETE /users/5 (token role=ADMIN) ===")
    val req5 = HttpRequest("DELETE", "/users/5",
        headers = mapOf("Authorization" to "Bearer JWT.2.Siti.ADMIN,USER"))
    val principal5 = jwt.extract(req5)
    if (principal5 != null && principal5.roles.contains("ADMIN")) {
        val resp = controller.deleteUser(req5, principal5)
        println("Status: ${resp.status}")
        println("Body: ${resp.body}")
    }

    println("\n=== Request 6: Token expired/invalid ===")
    val req6 = HttpRequest("GET", "/profile",
        headers = mapOf("Authorization" to "Bearer some.random.token"))
    val principal6 = jwt.extract(req6)
    if (principal6 == null) {
        println("Status: 401")
        println("""Body: {"error":"Invalid token"}""")
    }
}

Output yang Dihasilkan:

text
=== Request 1: GET /health (public) ===
Status: 200
Body: {"status":"UP"}

=== Request 2: GET /profile (tanpa Authorization) ===
Status: 401
Body: {"error":"Unauthorized"}

=== Request 3: GET /profile (token role=USER) ===
Status: 200
Body: {"id":1,"nama":"Budi"}

=== Request 4: DELETE /users/5 (token role=USER, butuh ADMIN) ===
Status: 403
Body: {"error":"Forbidden: butuh role ADMIN"}

=== Request 5: DELETE /users/5 (token role=ADMIN) ===
Status: 200
Body: {"deleted":true,"by":"Siti"}

=== Request 6: Token expired/invalid ===
Status: 401
Body: {"error":"Invalid token"}

Mari kita bedah bagian penting kode di atas:

  1. data class UserPrincipal(val id, val nama, val roles: Set<String>): model principal dengan set peran. Set memungkinkan cek keanggotaan cepat (contains("ADMIN")).
  2. class JwtFilter { fun extract(req): UserPrincipal? }: filter membaca header Authorization, menghapus prefix Bearer , dan mem-parse token. Format demo JWT.<id>.<nama>.<roles_csv> tidak aman untuk produksi — JWT nyata memakai HMAC atau RSA signature.
  3. object SecurityContext { private val holder = ThreadLocal<UserPrincipal?>() }: ThreadLocal menyimpan principal per-thread. Setiap request di thread sendiri, sehingga context tidak bocor antar request.
  4. @PreAuthorize("hasRole('ADMIN')"): anotasi yang dipindai Spring AOP. Saat method deleteUser dipanggil, aspect memeriksa apakah principal saat ini memiliki role ADMIN. Bila tidak, lempar AccessDeniedException.
  5. SecurityContext.set(principal3) dan SecurityContext.clear(): setelah handler selesai, context harus di-clear agar ThreadLocal tidak bocor ke request berikutnya di thread pool.

Konfigurasi SecurityFilterChain #

Spring Security 6 memakai SecurityFilterChain bean untuk konfigurasi:

kotlin SecurityFilterChain bean
@Bean
fun filterChain(http: HttpSecurity): SecurityFilterChain {
    http.csrf { it.disable() }
        .authorizeHttpRequests { auth ->
            auth.requestMatchers("/public/**").permitAll()
                .requestMatchers("/admin/**").hasRole("ADMIN")
                .anyRequest().authenticated()
        }
        .addFilterBefore(jwtFilter, UsernamePasswordAuthenticationFilter::class.java)
    return http.build()
}

Lambda http { } adalah DSL Kotlin yang menggantikan XML configuration lama. Kita menentukan URL publik vs privat, menambahkan filter kustom, dan menonaktifkan fitur yang tidak dipakai (mis. CSRF untuk stateless API).

Referensi Lanjutan #

Selanjutnya #

Setelah keamanan terbangun, langkah berikutnya adalah menjaga konsistensi data. Chapter Database Transactions akan membahas @Transactional, isolation level, dan prinsip ACID yang menjadi dasar transaksi database.