HackToTech

Hack To Technology

APIの権限チェックに@PreAuthorizeを使うのをやめた話

tl;dr

@PreAuthorize@Valid を同時に使用した場合に、評価の順番に対して直感的にはわかりづらい問題がある
Issueとしては↓があたるが、
要はコントローラーのリクエストハンドラーの変数の評価よりもあとに @PreAuthorize の評価がされる為、
本来であれば権限的に叩けないようなAPIに対してもリクエストボディなどの検証が先に動き、403で返すべきところを400で返す問題がある github.com

Issueにコメントしている人もいる通り、 BindingResultをハンドラー側で受け取れば回避も出来るだろうからそうするのも勿論ありではあると思う
ただ既に @Valid を大半に使っていて作りを変える気がない場合などは、 @PreAuthorize をそのまま使うことは控えたほうが個人的には良いと思う

@PreAuthorizeをやめてどうすることにしたか

自前でInterceptor書いてリクエストハンドラーの前に割り込むことにした
とりあえず対象のパスに来たらInterceptorを呼ばせる

@Configuration
class WebMcvConfig(
    private val authorizationInterceptor: AuthorizationInterceptor,
): WebMvcConfigurer {
    override fun addInterceptors(register: InterceptorRegistry) {
        registry.addInterceptor(authorizationInterceptor).addPathPatterns("<対象とするパス>")
    }
}

詳細はドキュメントを見たほうが早いが、
これでControllerのリクエストハンドラーより先に評価させることが出来る
なぜ HandlerMethod かどうかを確認しているかというと、 ResourceHttpRequestHandler が入るケースがあって、今回はその場合は評価させたくなかったのでこうしている

@Component
class AuthorizationInterceptor(
    private val authorizationEvaluator: AuthorizationEvaluator,
): HandlerInterceptor {
    override fun preHandle(
        request: HttpServletRequest,
        response: HttpServletResponse,
        handler: Any,
    ): Boolean {
        if (handler is HandlerMethod) authorizationEvaluator.evaluate(handler.method)
        return true
    }
}
@Component
class AuthorizationEvaluator {
    fun evaluate(method: Method): Boolean {
        // MethodからAnnotationを取得するなりして、権限が足りていればTrueを返して足りなければFalseを返す
    }
}

あとは自前でアノテーションを作って好きに評価することにした

今のところ特に問題がなくうまくいっているが、
もちろん @PreAuthorize のほうが便利だし、
今回はあくまで扱いたいケースが単純だったので楽に実装出来ただけなので、その辺もちゃんと見極めた上でAPIの権限チェックに何を使うかは考えたほうが良いと思う
ただ、ライブラリを使うのに比べれば自前で後で捨てやすいように作るほうが、個人的には好きなので今回はこうしてみた

ConstraintValidatorのisValidでエラーメッセージを変更する

isValid 内でValidationMessagseのキー指定して切り替えって出来るんだっけ?ってなったので、個人的なメモ書き

ドキュメントを読んでいたら出来そうだったので試した
あんまり使う機会はなさそうではある(基本的にはConstraintsは分けるし、相関チェックみたいなパターンぐらいの認識)

@Target(AnnotationTarget.CLASS)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [CustomConstraints.CustomConstraintValidator::class])
annotation class CustomConstraints(
    val message: String = "{com.example.customconstraintssample.constraints.CustomConstraints.message}",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
) {
    class CustomConstraintValidator : ConstraintValidator<CustomConstraints, IndexRequest> {
        override fun isValid(value: IndexRequest, context: ConstraintValidatorContext): Boolean {
            return when (value.type) {
                IndexType.TYPE1 -> {
                    if (value.type1Value != null && value.type2Value == null) return true
                    context.disableDefaultConstraintViolation()
                    context.buildConstraintViolationWithTemplate("{com.example.customconstraintssample.constraints.CustomConstraints.type1.message}")
                        .addConstraintViolation()
                    false
                }
                IndexType.TYPE2 -> {
                    if (value.type1Value == null && value.type2Value != null) return true
                    context.disableDefaultConstraintViolation()
                    context.buildConstraintViolationWithTemplate("{com.example.customconstraintssample.constraints.CustomConstraints.type2.message}")
                        .addConstraintViolation()
                    false
                }
            }
        }
    }
}

使う側

enum class IndexType {
    TYPE1,
    TYPE2,
}

@CustomConstraints
data class IndexRequest(
    val type: IndexType,
    val type1Value: String?,
    val type2Value: Int?,
)

試したソースはここ github.com

BeanValidationでControlCharacterが含まれる場合をバリデーションする

リクエストとかでControlCharacterが混じっていると邪魔になるので弾きたかったという話
調べてもすぐにぱぱぱっと出てこなかったりして面倒なのでメモがてら書く

どちらも空文字は許可するようにしてある
テストはソースに書いてあるので興味があれば

import jakarta.validation.Constraint
import jakarta.validation.Payload
import jakarta.validation.constraints.Pattern
import kotlin.reflect.KClass

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [])
@Pattern(regexp = "^$|[^\\p{Cntrl}]+")
annotation class RejectControlCharacters(
    val message: String = "{com.example.validatecontrolcharacterssample.constraints.RejectControlCharacters.message}",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
)

改行だけは許可したいみたいな場合はこっち

import jakarta.validation.Constraint
import jakarta.validation.Payload
import jakarta.validation.constraints.Pattern
import kotlin.reflect.KClass

@Target(AnnotationTarget.FIELD)
@Retention(AnnotationRetention.RUNTIME)
@Constraint(validatedBy = [])
@Pattern(regexp = "^$|[^[\\x00-\\x09\\x0B\\x0C\\x0E-\\x1F\\x7F]]+")
annotation class RejectControlCharactersOtherThanLineBreak(
    val message: String = "{com.example.validatecontrolcharacterssample.constraints.RejectControlCharactersOtherThanLineBreak.message}",
    val groups: Array<KClass<*>> = [],
    val payload: Array<KClass<out Payload>> = []
)

書いたソースはここ github.com

SpringでRollbackされた時に別トランザクションでデータの保存をしようとしてたのでメモ

大まかなコードのイメージ

package com.example.app.application

import com.example.app.domain.Sample
import com.example.app.domain.SampleRepository
import org.springframework.aop.framework.AopContext
import org.springframework.stereotype.Service
import org.springframework.transaction.annotation.Propagation
import org.springframework.transaction.annotation.Transactional
import org.springframework.transaction.interceptor.TransactionAspectSupport

@Service
class SampleService(
    private val otherService: OtherService,
    private val fallbackService: FallbackService,
) {
    @Transactional
    fun handle() {
        try {
            // ここの呼び出しがロールバックするなら処理を保存しときたい
           // 実際は他にもトランザクションを使ったなんらかの処理
            otherService.handle()
        } catch (e: RuntimeException) {
            // UnexpectedRollbackExceptionにならないように明示的に指定する必要がある
            val transactionStatus = TransactionAspectSupport.currentTransactionStatus()
            transactionStatus.setRollbackOnly()
            fallbackService.handle()
        }
    }
}

@Service
class OtherService {
    @Transactional
    fun handle() {
        // 実際はなんらかの処理
        throw RuntimeException("runtime error")
    }
}

@Service
class FallbackService(
    private val repository: SampleRepository,
) {
    @Transactional(propagation = Propagation.REQUIRES_NEW)
    fun handle() {
        // 実際はデータ受け取って書き込むだけ
        val sample = Sample.of()
        repository.save(sample)
    }
}

なんかこの為だけに別クラス作って呼び出したり、EventPublisher経由で別メソッドを呼び出したりするのがイマイチだなあと思ってたらAOPで解決する方法もあるとのことだった
www.youtube.com 詳解Springトランザクション -初級から上級まで- #jsug | ドクセル


別メソッドで Propagation.REQUIRES_NEW しているなら、トランザクション別で自動的に貼り直してくれれば良いのに
と思っていたらAOPの仕組み的に SampleService#handle に入る際にProxy経由で呼び出されて、
SampleService#handle 内で 別メソッドを呼び出しても割り込まれずに直接呼び出される(Annotationが効かない)という話っぽい
この辺ちゃんと頭の中でイメージ出来ていないと、なんで書いた通りに呼び出されないんだ???となるのでとても良い資料だった

解説されていたサイトの方、どうやってProxy経由で呼び出しているのかちょっとだけ見てみた

あとは SampleService#handle から SampleService(Proxy経由)#別メソッド を呼びだしている、ということっぽい

SpringBootでTomcatの特定のValveを削除する

タイトル通り
あんまり使い道はないと思われるが、ちょっと簡単に削除が出来るのか気になったので個人的なメモ
消すのはRemoteIpValve
設定の話はこの辺

バージョン

  • SpringBoot(2.7.5)
# application.properties
server.tomcat.remoteip.protocol-header=x-forwarded-for
import org.springframework.boot.web.embedded.tomcat.TomcatServletWebServerFactory
import org.springframework.boot.web.server.WebServerFactoryCustomizer
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.apache.catalina.valves.RemoteIpValve


@Configuration
class TomcatConfig {
    @Bean
    fun remoteIpValveRemover(): WebServerFactoryCustomizer<TomcatServletWebServerFactory> {
        return WebServerFactoryCustomizer { factory: TomcatServletWebServerFactory ->
            factory.engineValves.removeIf { it is RemoteIpValve }
        }
    }
}
import org.springframework.http.ResponseEntity
import org.springframework.web.bind.annotation.GetMapping
import org.springframework.web.bind.annotation.RequestHeader
import org.springframework.web.bind.annotation.RestController
import javax.servlet.http.HttpServletRequest


@RestController
class BaseController {
    @GetMapping("/")
    fun base(
        request: HttpServletRequest,
        @RequestHeader("X-Forwarded-For") xForwardedFor: String?,
    ): ResponseEntity<Any> {
        println("X-Forwarded-For: $xForwardedFor")
        return ResponseEntity.ok(request.remoteAddr)
    }
}

Removeした場合

curl http://localhost:8080/ -H 'X-Forwarded-For: 192.168.0.1'
127.0.0.1

# サーバ側のログ
X-Forwarded-For: 192.168.0.1

Removeしなかった場合

curl http://localhost:8080/ -H 'X-Forwarded-For: 192.168.0.1'
192.168.0.1

# サーバ側のログ
X-Forwarded-For: null

結論: 簡単に出来る