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.
HTTP Request
|
v
[SecurityContextPersistenceFilter] -> muat konteks dari session
|
v
[JwtAuthenticationFilter] -> verifikasi Bearer token
|
v
[AuthorizationFilter] -> cek URL + role
|
v
[DispatcherServlet] -> handler Spring MVCFilter 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:
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.
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.
@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.
// 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:
=== 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:
data class UserPrincipal(val id, val nama, val roles: Set<String>): model principal dengan set peran. Set memungkinkan cek keanggotaan cepat (contains("ADMIN")).class JwtFilter { fun extract(req): UserPrincipal? }: filter membaca headerAuthorization, menghapus prefixBearer, dan mem-parse token. Format demoJWT.<id>.<nama>.<roles_csv>tidak aman untuk produksi — JWT nyata memakai HMAC atau RSA signature.object SecurityContext { private val holder = ThreadLocal<UserPrincipal?>() }: ThreadLocal menyimpan principal per-thread. Setiap request di thread sendiri, sehingga context tidak bocor antar request.@PreAuthorize("hasRole('ADMIN')"): anotasi yang dipindai Spring AOP. Saat methoddeleteUserdipanggil, aspect memeriksa apakah principal saat ini memiliki roleADMIN. Bila tidak, lemparAccessDeniedException.SecurityContext.set(principal3)danSecurityContext.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:
@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 #
- Spring Security reference
— rujukan resmi Spring Security untuk
SecurityFilterChain, autentikasi, dan otorisasi
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.