Skip to content

Commit

Permalink
recaptcha filter
Browse files Browse the repository at this point in the history
  • Loading branch information
suayb committed Aug 29, 2023
1 parent 96d8692 commit e31d75a
Show file tree
Hide file tree
Showing 10 changed files with 111 additions and 50 deletions.
Original file line number Diff line number Diff line change
@@ -1,6 +1,5 @@
package io.github.susimsek.springgraphqlsamples.config

import io.github.susimsek.springgraphqlsamples.security.recaptcha.GraphQlRecaptchaHeaderInterceptor
import io.github.susimsek.springgraphqlsamples.security.recaptcha.RecaptchaClient
import io.github.susimsek.springgraphqlsamples.security.recaptcha.RecaptchaProperties
import io.github.susimsek.springgraphqlsamples.security.recaptcha.RecaptchaService
Expand Down Expand Up @@ -36,9 +35,4 @@ class RecaptchaConfig {
): RecaptchaService {
return RecaptchaService(recaptchaClient, recaptchaProperties)
}

@Bean
fun graphQlRecaptchaInterceptor(): GraphQlRecaptchaHeaderInterceptor {
return GraphQlRecaptchaHeaderInterceptor()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import io.github.susimsek.springgraphqlsamples.security.jwt.AUTHORITIES_KEY
import io.github.susimsek.springgraphqlsamples.security.jwt.JwtDecoder
import io.github.susimsek.springgraphqlsamples.security.jwt.TokenAuthenticationConverter
import io.github.susimsek.springgraphqlsamples.security.jwt.TokenProvider
import io.github.susimsek.springgraphqlsamples.security.recaptcha.RecaptchaFilter
import io.github.susimsek.springgraphqlsamples.security.recaptcha.RecaptchaService
import io.github.susimsek.springgraphqlsamples.security.xss.XSSFilter
import org.springframework.boot.context.properties.EnableConfigurationProperties
import org.springframework.context.annotation.Bean
Expand Down Expand Up @@ -114,7 +116,8 @@ class SecurityConfig {
securityProperties: SecurityProperties,
jwtAuthenticationConverter: Converter<Jwt, Mono<AbstractAuthenticationToken>>,
bearerTokenConverter: ServerAuthenticationConverter,
securityExceptionResolver: ReactiveSecurityExceptionResolver
securityExceptionResolver: ReactiveSecurityExceptionResolver,
recaptchaService: RecaptchaService
): SecurityWebFilterChain {
// @formatter:off
http
Expand Down Expand Up @@ -161,6 +164,7 @@ class SecurityConfig {
.accessDeniedHandler(securityExceptionResolver)
}
.addFilterBefore(XSSFilter(), SecurityWebFiltersOrder.AUTHENTICATION)
.addFilterBefore(RecaptchaFilter(recaptchaService), SecurityWebFiltersOrder.AUTHENTICATION)
// @formatter:on
return http.build()
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,19 @@ import graphql.GraphQLError
import graphql.GraphqlErrorBuilder
import graphql.schema.DataFetchingEnvironment
import io.github.susimsek.springgraphqlsamples.exception.FORBIDDEN_MSG_CODE
import io.github.susimsek.springgraphqlsamples.exception.INTERNAL_SERVER_ERROR_MSG_CODE
import io.github.susimsek.springgraphqlsamples.exception.InvalidCaptchaException
import io.github.susimsek.springgraphqlsamples.exception.UNAUTHORIZED_MSG_CODE
import io.github.susimsek.springgraphqlsamples.exception.model.Problem
import io.github.susimsek.springgraphqlsamples.exception.utils.WebExceptionUtils
import jakarta.annotation.Priority
import org.slf4j.LoggerFactory
import org.springframework.boot.web.reactive.error.ErrorWebExceptionHandler
import org.springframework.context.MessageSource
import org.springframework.graphql.execution.ErrorType
import org.springframework.http.HttpStatus
import org.springframework.http.ResponseEntity
import org.springframework.http.codec.HttpMessageWriter
import org.springframework.security.access.AccessDeniedException
import org.springframework.security.authentication.AuthenticationTrustResolver
import org.springframework.security.authentication.AuthenticationTrustResolverImpl
Expand All @@ -20,20 +26,26 @@ import org.springframework.security.core.context.ReactiveSecurityContextHolder
import org.springframework.security.core.context.SecurityContext
import org.springframework.security.web.server.ServerAuthenticationEntryPoint
import org.springframework.security.web.server.authorization.ServerAccessDeniedHandler
import org.springframework.stereotype.Component
import org.springframework.web.bind.annotation.ControllerAdvice
import org.springframework.web.reactive.function.server.HandlerStrategies
import org.springframework.web.reactive.function.server.ServerResponse
import org.springframework.web.reactive.result.view.ViewResolver
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono
import reactor.kotlin.core.publisher.switchIfEmpty
import java.util.*

@Component
@ControllerAdvice
@Priority(0)
class ReactiveSecurityExceptionResolver(
private val messageSource: MessageSource,
private val mapper: ObjectMapper
) : ServerAuthenticationEntryPoint, ServerAccessDeniedHandler {
) : ServerAuthenticationEntryPoint, ServerAccessDeniedHandler, ErrorWebExceptionHandler {

private val trustResolver: AuthenticationTrustResolver = AuthenticationTrustResolverImpl()

private val log = LoggerFactory.getLogger(javaClass)

fun resolveException(
ex: Throwable,
env: DataFetchingEnvironment,
Expand Down Expand Up @@ -74,6 +86,30 @@ class ReactiveSecurityExceptionResolver(
return Problem.build(HttpStatus.UNAUTHORIZED, errorMessage, exchange)
}

private fun internalServerError(exchange: ServerWebExchange): Problem {
val errorMessage = messageSource.getMessage(INTERNAL_SERVER_ERROR_MSG_CODE, null, resolveLocale(exchange))
return Problem.build(HttpStatus.INTERNAL_SERVER_ERROR, errorMessage, exchange)
}

private fun invalidCaptchaException(exchange: ServerWebExchange,
exception: InvalidCaptchaException): Mono<ServerResponse> {
val errorMessage = messageSource.getMessage(
exception.message,
null,
resolveLocale(exchange)
)
val problem = Problem.build(HttpStatus.BAD_REQUEST, errorMessage, exchange)
return ServerResponse
.status(HttpStatus.BAD_REQUEST)
.bodyValue(problem)
}

private fun defaultException(exchange: ServerWebExchange): Mono<ServerResponse> {
return ServerResponse
.status(HttpStatus.INTERNAL_SERVER_ERROR)
.bodyValue(internalServerError(exchange))
}

private fun accessDenied(
env: DataFetchingEnvironment,
securityContext: SecurityContext,
Expand Down Expand Up @@ -122,4 +158,28 @@ class ReactiveSecurityExceptionResolver(
return resolveException(denied, exchange)
.flatMap { WebExceptionUtils.setHttpResponse(exchange, accessDenied(exchange), mapper) }
}

override fun handle(exchange: ServerWebExchange, ex: Throwable): Mono<Void> {
log.error("error occurred ${ex.javaClass.simpleName}", ex)

val sr = when (ex) {
is InvalidCaptchaException -> invalidCaptchaException(exchange, ex)
else -> defaultException(exchange)
}

return sr.flatMap { it.writeTo(exchange, ResponseContextInstance) }.then()
}

private object ResponseContextInstance : ServerResponse.Context {

val strategies: HandlerStrategies = HandlerStrategies.withDefaults()

override fun messageWriters(): List<HttpMessageWriter<*>> {
return strategies.messageWriters()
}

override fun viewResolvers(): List<ViewResolver> {
return strategies.viewResolvers()
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import io.github.susimsek.springgraphqlsamples.graphql.REFRESH_TOKEN_CONTEXT_NAM
import io.github.susimsek.springgraphqlsamples.graphql.TOKEN_CONTEXT_NAME
import io.github.susimsek.springgraphqlsamples.graphql.input.LoginInput
import io.github.susimsek.springgraphqlsamples.graphql.type.TokenPayload
import io.github.susimsek.springgraphqlsamples.security.recaptcha.RecaptchaService
import io.github.susimsek.springgraphqlsamples.service.AuthenticationService
import org.slf4j.LoggerFactory
import org.springframework.graphql.data.method.annotation.Argument
Expand All @@ -19,8 +18,7 @@ import java.util.*

@Controller
class AuthenticationController(
private val authenticationService: AuthenticationService,
private val recaptchaService: RecaptchaService
private val authenticationService: AuthenticationService
) {

private val log = LoggerFactory.getLogger(javaClass)
Expand All @@ -32,7 +30,6 @@ class AuthenticationController(
locale: Locale,
context: GraphQLContext
): TokenPayload {
recaptchaService.validateToken(recaptcha)
log.info("locale: {}", locale.toLanguageTag())
val token = authenticationService.authorize(input)
context.put(TOKEN_CONTEXT_NAME, token)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,6 @@ import io.github.susimsek.springgraphqlsamples.graphql.input.ResetPasswordInput
import io.github.susimsek.springgraphqlsamples.graphql.input.UserFilter
import io.github.susimsek.springgraphqlsamples.graphql.type.PagedEntityModel
import io.github.susimsek.springgraphqlsamples.graphql.type.UserPayload
import io.github.susimsek.springgraphqlsamples.security.recaptcha.RecaptchaService
import io.github.susimsek.springgraphqlsamples.service.UserService
import org.springframework.data.domain.PageRequest
import org.springframework.data.domain.Sort
Expand All @@ -23,16 +22,14 @@ import org.springframework.stereotype.Controller

@Controller
class UserController(
private val userService: UserService,
private val recaptchaService: RecaptchaService
private val userService: UserService
) {
@MutationMapping
suspend fun createUser(
@Argument
input: AddUserInput,
@ContextValue(required = false) recaptcha: String?
): UserPayload {
recaptchaService.validateToken(recaptcha)
return userService.createUser(input)
}

Expand All @@ -49,10 +46,8 @@ class UserController(

@MutationMapping
suspend fun forgotPassword(
@Argument email: String,
@ContextValue(required = false) recaptcha: String?
@Argument email: String
): Boolean {
recaptchaService.validateToken(recaptcha)
return userService.forgotPassword(email)
}

Expand Down

This file was deleted.

Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package io.github.susimsek.springgraphqlsamples.security.recaptcha

import io.github.susimsek.springgraphqlsamples.exception.InvalidCaptchaException
import io.github.susimsek.springgraphqlsamples.exception.RECAPTCHA_INVALID_MSG_CODE
import io.github.susimsek.springgraphqlsamples.graphql.RECAPTCHA_HEADER_NAME
import kotlinx.coroutines.reactor.awaitSingleOrNull
import org.springframework.boot.autoconfigure.security.SecurityProperties
import org.springframework.core.annotation.Order
import org.springframework.web.server.CoWebFilter
import org.springframework.web.server.CoWebFilterChain
import org.springframework.web.server.ServerWebExchange
import reactor.core.publisher.Mono






@Order(SecurityProperties.DEFAULT_FILTER_ORDER-1)
class RecaptchaFilter(
private val recaptchaService: RecaptchaService,
): CoWebFilter() {

override suspend fun filter(exchange: ServerWebExchange, chain: CoWebFilterChain) {
val recaptcha = exchange.request.headers.getFirst(RECAPTCHA_HEADER_NAME)
val success = recaptchaService.validateToken(recaptcha)
if (!success) {
return exchange.response.writeWith(Mono.error(InvalidCaptchaException(RECAPTCHA_INVALID_MSG_CODE)))
.cast(Unit.javaClass).awaitSingleOrNull() ?: Unit
}
return chain.filter(exchange)
}
}
Original file line number Diff line number Diff line change
@@ -1,7 +1,5 @@
package io.github.susimsek.springgraphqlsamples.security.recaptcha

import io.github.susimsek.springgraphqlsamples.exception.InvalidCaptchaException
import io.github.susimsek.springgraphqlsamples.exception.RECAPTCHA_INVALID_MSG_CODE
import kotlinx.coroutines.reactive.awaitSingle
import org.springframework.util.StringUtils
import java.util.regex.Pattern
Expand All @@ -14,15 +12,15 @@ class RecaptchaService(
private val responsePattern = Pattern.compile("[A-Za-z0-9_-]+")
suspend fun validateToken(recaptchaToken: String?): Boolean {
if (!recaptchaProperties.enabled) {
return false
return true
}
if (recaptchaToken == null || !responseSanityCheck(recaptchaToken)) {
throw InvalidCaptchaException(RECAPTCHA_INVALID_MSG_CODE)
return false
}
val response = recaptchaClient.verifyResponse(recaptchaProperties.secretKey, recaptchaToken)
.awaitSingle()
if (!response.success || response.score < recaptchaProperties.threshold) {
throw InvalidCaptchaException(RECAPTCHA_INVALID_MSG_CODE)
return false
}
return true
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,12 @@
package io.github.susimsek.springgraphqlsamples.security.xss

import org.springframework.boot.autoconfigure.security.SecurityProperties
import org.springframework.core.annotation.Order
import org.springframework.web.server.CoWebFilter
import org.springframework.web.server.CoWebFilterChain
import org.springframework.web.server.ServerWebExchange

@Order(SecurityProperties.DEFAULT_FILTER_ORDER-2)
class XSSFilter: CoWebFilter() {

override suspend fun filter(exchange: ServerWebExchange, chain: CoWebFilterChain) {
Expand Down
2 changes: 1 addition & 1 deletion src/main/resources/i18n/messages.properties
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ error.roleName.not.found.message=Role with name {0} was not found
error.post.not.found.message=Post with id {0} was not found.
error.username.already.exists.message=Username is already in use!
error.email.already.exists.message=Email is already in use!
error.recaptcha.invalid.message=reCaptcha validation
error.recaptcha.invalid.message=reCaptcha validation failed
error.password.invalid.message=Incorrect password

email.activation.title=Registration Confirmation
Expand Down

0 comments on commit e31d75a

Please sign in to comment.