IP Block On
IP Block Off
SpringBoot의 Filter를 통해 White Cidr(White IP List)를 구현하여 등록된 IP만 접근 가능하게 만들도록 IP를 제한하는 것이 본 포스팅의 목표입니다. 제한된 IP는 Block Page로 넘어가게 됩니다.
IP 제한 기능은 프론트/백을 다음과 같이 나누어 개발합니다.
해당 기능을 제작하기 위해 다음 기능들을 구현합니다.
해당 기능을 제작하기 위해 다음 기능들을 구현할 것입니다.
ip와 active 데이터를 전달하기 위한 객체를 생성합니다.
data class UserIpDto(
var ip: String,
var active: Boolean,
)
data class UserIp(
@field:NotBlank
var ip: String,
@field:NotBlank
var active: Boolean
) {
fun toDto() = UserIpDto(ip, active)
}
data class Onlyid(
@field:NotBlank
var id: String
)
JOOQ(Java Object Oriented Querying)를 사용해서 SQL을 다룰 것입니다.
JOOQ와 JPA 차이점은 다음 글에서 정리해두었습니다.
CREATE TABLE IF NOT EXISTS `IPS`.`Whitelistip`
(
`ip` VARCHAR(15) NOT NULL,
`active` BOOLEAN NOT NULL
);
IP와 활성화 여부를 저장할 테이블을 만들어줍니다.
CRD 기능과 활성화 기능을 위해 각 함수를 만듭니다. ipActive의 결과를 반환만 해주는 getIpActiveResult 함수는 뒤에 나올 IpFilter 부분에서 활용합니다.
@Service
class IpService @Autowired constructor(
private val ctx: DSLContext
) {
suspend fun getIps(): Flow<UserIpDto> {
val table = Whitelistip
val query = ctx.selectFrom(table)
return JooqQuery.findAllFlow(query).mapNotNull { record ->
UserIpDto(
ip = record.get(table.ip),
active = record.get(table.active)
)
}
}
suspend fun addIp(ipDto: UserIpDto): Boolean {
val table = Whitelistip
val query = ctx.insertInto(table)
.set(table.ip, ipDto.ip)
.set(table.active, ipDto.active)
val result = JooqQuery.execute(query)
return result.isNotEmpty() && result[0] == 1
}
suspend fun deleteIp(ip: String): Boolean {
val table = Whitelistip
val query = ctx.deleteFrom(table)
.where(table.ip.eq(ip))
val result = JooqQuery.execute(query)
return result.isNotEmpty() && result[0] == 1
}
suspend fun ipActive(active: Boolean): Boolean {
val table = Whitelistip
val query = ctx.update(table)
.set(table.active, active)
val result = JooqQuery.execute(query)
return result.isNotEmpty() && result[0] > 0
}
suspend fun getIpActiveResult(): Boolean {
val table = Whitelistip
val query = ctx.select(table.active)
.from(table)
.limit(1) // Assuming you only want one result
val result = JooqQuery.findOne(query) ?: throw RuntimeException("not null")
return result.getValue(table.active)
}
}
API 요청을 위해 컨트롤러를 만들어줍니다. 프론트와 통신하기 위한 명세서는 다음과 같습니다.
- ip 체크 - get : /api/checkIp
res { "result" : false } // ip-block이 활성화 중이고 사용자 ip가 등록된 ip가 아니라면 false, 등록된 ip면 true. ip-block이 활성화 중이 아니라면 항상 true
- ip 조회 - get : /api/getIps
res { "active" : true, "result" : [ "192.132.121.1", "192.132.121.1", "192.132.121.1" ] } // ip 리스트 전달
- ip 추가 - post : /api/addIp
req { "ip" : "192.132.121.1" } // 추가할 ipres { "result" : false } // 동일한 ip가 존재하거나 다른 이유로 추가에 실패 시 false 아니면 true
- ip 삭제 - post : /api/deleteIp
req { "ip" : "192.132.121.1" } // 삭제할 ip
res { "result" : false } // 삭제에 실패 시 false 아니면 true
- ip block 활성화 비활성화 - post : /api/ipActive
req { "active" : true } // ip-block 상태 변경res { "result" : false } // 변경 실패 시 false 아니면 true
checkIp에서는 요청 온 주소를 뽑아서 블록킹 처리해줬습니다. 활성화 여부와 DB에 저장된 값에 따라(ipAllowed) 결과 값을 응답해줍니다.
@Tag(name = "IP")
@RestController
@RequestMapping("/api")
class IpController @Autowired constructor(private val ipService: IpService, private val ipFilter: IpFilter) {
@Operation(summary = "Check IP")
@GetMapping("/checkIp")
suspend fun checkIp(exchange: ServerWebExchange): Map<String, Boolean> {
val ipAddress = exchange.request.remoteAddress?.address?.hostAddress
?: throw MissingRequestValueException("Required query parameter 'ip' is not present.")
println("ipAddress: $ipAddress")
val ipAllowed = ipFilter.isIpAllowed(ipAddress).awaitSingle()
return mapOf("result" to ipAllowed)
}
@Operation(summary = "Get IPs")
@GetMapping("/getIps")
suspend fun getIps(): Map<String, Any> {
val ipsWithStatus = ipService.getIps().toList()
val activeStatus = ipsWithStatus.firstOrNull()?.active ?: false
return mapOf(
"active" to activeStatus,
"result" to ipsWithStatus.map { it.ip }
)
}
@Operation(summary = "Add IP")
@PostMapping("/addIp")
suspend fun addIp(@RequestBody request: Map<String, String>): Map<String, Boolean> {
val ip = request["ip"] ?: error("Missing 'ip' field in the request")
val success = ipService.addIp(UserIpDto(ip, active = false))
return mapOf("result" to success)
}
@Operation(summary = "Delete IP")
@PostMapping("/deleteIp")
suspend fun deleteIp(@RequestBody request: Map<String, String>): Map<String, Boolean> {
val ip = request["ip"] ?: error("Missing 'ip' field in the request")
val success = ipService.deleteIp(ip)
return mapOf("result" to success)
}
@Operation(summary = "Set IP Active")
@PostMapping("/ipActive")
suspend fun ipActive(@RequestBody request: Map<String, Boolean>): Map<String, Boolean> {
val active = request["active"] ?: error("Missing 'active' field in the request")
val success = ipService.ipActive(active)
return mapOf("result" to success)
}
}
filter 함수
extractIpFromRequest에서 추출한 IP 주소를 기준에 따라 IP가 허용되는지 확인합니다. IP가 허용되면 authenticationConverter.convert 함수가 호출되어 허용된 IP의 요청을 인증 토큰으로 변환하고 사용자 인증 정보를 포함하여 securityContext에 저장됩니다. IP가 허용되지 않는다면 필터 체인이 중지됩니다. then 나머지 필터 실행을 계속 진행합니다.
isIpAllowed함수
Ip block이 활성화(isIpBlockActive=true라면) 상태라면 Whitelistip DB에 해당 IP가 있는지 확인합니다. DB가 비어있거나 해당 IP가 존재한다면 true를 반환해주고 해당 IP가 존재하지 않는다면 false를 반환해줍니다. 그리고 Ip block이 비활성화(isIpBlockActive=false라면) 되어 있다면 모든 IP를 허용해줍니다.
@Component
class IpFilter(
private val authenticationConverter: ServerAuthenticationConverter,
private val ipService: IpService
) : WebFilter {
private val securityContextRepository: ServerSecurityContextRepository = WebSessionServerSecurityContextRepository()
override fun filter(exchange: ServerWebExchange, chain: WebFilterChain): Mono<Void> {
return extractIpFromRequest(exchange)
.flatMap { ip ->
isIpAllowed(ip)
.flatMap { allowed ->
if (allowed) {
authenticationConverter.convert(exchange)
.flatMap { authentication ->
val securityContext = SecurityContextImpl(authentication)
securityContextRepository.save(exchange, securityContext)
}
} else {
Mono.empty()
}
}
}
.then(chain.filter(exchange))
}
private fun extractIpFromRequest(exchange: ServerWebExchange): Mono<String> {
return Mono.justOrEmpty(exchange.request.remoteAddress?.address?.hostAddress)
}
fun isIpAllowed(receivingIp: String): Mono<Boolean> {
return mono {
val isIpBlockActive = ipService.getIpActiveResult()
println("Is IP Block Active: $isIpBlockActive")
if (isIpBlockActive) {
// If the IP block is active, check the whitelist
val whitelistIps = ipService.getIps().map { it.ip }.toList()
println("Whitelist IPs: $whitelistIps")
val isAllowed = whitelistIps.isEmpty() || receivingIp in whitelistIps
println("Is IP Allowed: $isAllowed")
isAllowed
} else {
println("IP Block is not active. Allowing all IPs.")
true
}
}.onErrorReturn(true) // Ensure that in case of any error, it returns true
}
}
보안 필터 체인에 필터를 추가하여 테스트 합니다.
@Configuration
@EnableWebFluxSecurity
class SecurityConfig {
// ... existing code ...
@Bean
fun securityWebFilterChain(
http: ServerHttpSecurity,
converter: JwtServerAuthenticationConverter,
authManager: JwtAuthenticationManager,
ipWhitelistFilter: IpWhitelistFilter
): SecurityWebFilterChain? {
val filter = AuthenticationWebFilter(authManager)
filter.setServerAuthenticationConverter(converter)
http
// ... existing code ...
.addFilterAt(ipWhitelistFilter, SecurityWebFiltersOrder.FIRST) // Add IP whitelist filter
return http.build()
}
}