Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 2 additions & 1 deletion .env.dev.defaults
Original file line number Diff line number Diff line change
Expand Up @@ -28,7 +28,8 @@ MONITORENV_OIDC_ENABLED=false
MONITORENV_OIDC_LOGIN_URL=/oauth2/authorization/proconnect
MONITORENV_OIDC_SUCCESS_URL=http://localhost:3000
MONITORENV_OIDC_ERROR_URL=http://localhost:3000/register
MONITORENV_OIDC_AUTHORIZED_SIRETS=1234567890,0987654321
MONITORENV_OIDC_BYPASS_DOMAINS_FILTER=true
MONITORENV_OIDC_AUTHORIZED_EMAIL_DOMAINS=
MONITORENV_OIDC_CLIENT_ID=monitorenv
MONITORENV_OIDC_CLIENT_SECRET=PNKVjpo4DNCVMCVYUZiS3wepPJdtdFhH
MONITORENV_OIDC_REDIRECT_URI=http://localhost:8880/login/oauth2/code/proconnect
Expand Down
3 changes: 2 additions & 1 deletion .env.infra.example
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ MONITORENV_OIDC_ENABLED=
MONITORENV_OIDC_LOGIN_URL=
MONITORENV_OIDC_SUCCESS_URL=
MONITORENV_OIDC_ERROR_URL=
MONITORENV_OIDC_AUTHORIZED_SIRETS=
MONITORENV_OIDC_BYPASS_DOMAINS_FILTER=
MONITORENV_OIDC_AUTHORIZED_EMAIL_DOMAINS=
MONITORENV_OIDC_CLIENT_ID=
MONITORENV_OIDC_CLIENT_SECRET=
MONITORENV_OIDC_REDIRECT_URI=
Expand Down
3 changes: 2 additions & 1 deletion .env.test.defaults
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ MONITORENV_OIDC_ENABLED=false
MONITORENV_OIDC_LOGIN_URL=/oauth2/authorization/proconnect
MONITORENV_OIDC_SUCCESS_URL=http://localhost:3000
MONITORENV_OIDC_ERROR_URL=http://localhost:3000/register
MONITORENV_OIDC_AUTHORIZED_SIRETS=1234567890,0987654321
MONITORENV_OIDC_BYPASS_DOMAINS_FILTER=true
MONITORENV_OIDC_AUTHORIZED_EMAIL_DOMAINS=
MONITORENV_OIDC_CLIENT_ID=monitorenv
MONITORENV_OIDC_CLIENT_SECRET=PNKVjpo4DNCVMCVYUZiS3wepPJdtdFhH
MONITORENV_OIDC_REDIRECT_URI=http://localhost:8880/login/oauth2/code/proconnect
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,14 @@ import org.springframework.context.annotation.Configuration
@ConfigurationProperties(prefix = "monitorenv.oidc")
class OIDCProperties {
var enabled: Boolean? = false
var bypassSiretFilter: String? = "false"
var bypassEmailDomainsFilter: String? = "false"
var clientId: String = ""
var clientSecret: String = ""
var redirectUri: String = ""
var loginUrl: String = ""
var successUrl: String = ""
var errorUrl: String = ""
var authorizedSirets: List<String> = listOf()
var authorizedEmailDomains: List<String> = listOf()
var issuerUri: String = ""

/**
Expand Down
Original file line number Diff line number Diff line change
@@ -1,20 +1,29 @@
package fr.gouv.cacem.monitorenv.config

import com.fasterxml.jackson.databind.ObjectMapper
import fr.gouv.cacem.monitorenv.infrastructure.api.endpoints.publicapi.SpaController.Companion.FRONTEND_APP_ROUTES
import jakarta.servlet.http.HttpServletRequest
import jakarta.servlet.http.HttpServletResponse
import org.slf4j.Logger
import org.slf4j.LoggerFactory
import org.springframework.boot.autoconfigure.condition.ConditionalOnProperty
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.http.HttpEntity
import org.springframework.http.HttpHeaders
import org.springframework.http.HttpMethod
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.core.AuthenticationException
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest
import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserService
import org.springframework.security.oauth2.client.oidc.web.logout.OidcClientInitiatedLogoutSuccessHandler
import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
import org.springframework.security.oauth2.core.OAuth2AuthenticationException
import org.springframework.security.oauth2.core.OAuth2Error
import org.springframework.security.oauth2.core.oidc.OidcUserInfo
import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser
import org.springframework.security.oauth2.core.oidc.user.OidcUser
import org.springframework.security.web.SecurityFilterChain
import org.springframework.security.web.authentication.AuthenticationFailureHandler
Expand All @@ -23,52 +32,138 @@
import org.springframework.security.web.authentication.SimpleUrlAuthenticationSuccessHandler
import org.springframework.security.web.authentication.logout.LogoutSuccessHandler
import org.springframework.security.web.util.matcher.AntPathRequestMatcher
import org.springframework.web.client.RestTemplate
import org.springframework.web.cors.CorsConfiguration
import org.springframework.web.cors.CorsConfigurationSource
import org.springframework.web.cors.UrlBasedCorsConfigurationSource
import kotlin.String
import kotlin.apply
import java.util.*

@Configuration
@EnableWebSecurity
class SecurityConfig(
val oidcProperties: OIDCProperties,
val clientRegistrationRepository: ClientRegistrationRepository?,
) {
private val logger = LoggerFactory.getLogger(SecurityConfig::class.java)
private val logger: Logger = LoggerFactory.getLogger(SecurityConfig::class.java)

@Bean
@ConditionalOnProperty(value = ["monitorenv.oidc.enabled"], havingValue = "true")
fun customOidcUserService(): OidcUserService {

Check failure on line 51 in backend/src/main/kotlin/fr/gouv/cacem/monitorenv/config/SecurityConfig.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Refactor this method to reduce its Cognitive Complexity from 18 to the 15 allowed.

See more on https://sonarcloud.io/project/issues?id=MTES-MCT_monitorenv&issues=AZrfEBxJUYIu91RCY607&open=AZrfEBxJUYIu91RCY607&pullRequest=2638
return object : OidcUserService() {
override fun loadUser(userRequest: OidcUserRequest): OidcUser {
private val restTemplate = RestTemplate()
private val objectMapper = ObjectMapper()

override fun loadUser(userRequest: OidcUserRequest): OidcUser =
try {
val oidcUser = super.loadUser(userRequest)
if (oidcProperties.bypassSiretFilter == "true") {
logger.info("OIDC is with Cerbère, bypassing the SIRET checks.")

return oidcUser
}
validateAndProcessUser(oidcUser)
} catch (e: Exception) {
// ProConnect returns userinfo as JWT instead of JSON
if (e.message?.contains("application/jwt") == true) {
logger.info("UserInfo endpoint returned JWT content type, decoding JWT response...")

val siretsClaimRaw = oidcUser.claims["SIRET"]
try {
val oidcUser = loadUserFromJwtUserInfo(userRequest)

val tokenSirets: Set<String> =
when (siretsClaimRaw) {
is List<*> -> siretsClaimRaw.filterIsInstance<String>().toSet()
is String -> setOf(siretsClaimRaw)
else -> throw OAuth2AuthenticationException("SIRET claim missing or malformed")
validateAndProcessUser(oidcUser)
} catch (jwtError: Exception) {
logger.error("⛔ Failed to decode JWT userinfo response", jwtError)

throw OAuth2AuthenticationException(
OAuth2Error(
"invalid_user_info_response",
"Failed to decode JWT userinfo response: ${jwtError.message}",
null,
),
jwtError as Throwable,
)
}
} else {
logger.error("⛔ Exception in loadUser", e)
throw e
}
}

private fun loadUserFromJwtUserInfo(userRequest: OidcUserRequest): OidcUser {
val userInfoUri = userRequest.clientRegistration.providerDetails.userInfoEndpoint.uri
val accessToken = userRequest.accessToken.tokenValue

logger.debug("Fetching JWT userinfo from: $userInfoUri")

val isAuthorized = oidcProperties.authorizedSirets.any { it in tokenSirets }
if (!isAuthorized) {
throw OAuth2AuthenticationException("User not authorized for the requested SIRET(s)")
val headers =
HttpHeaders().apply {
setBearerAuth(accessToken)
}

val response =
restTemplate.exchange(
userInfoUri,
HttpMethod.GET,
HttpEntity<String>(headers),
String::class.java,
)

val jwtToken = response.body ?: throw IllegalArgumentException("Empty userinfo response")

val claims = decodeJwtClaims(jwtToken)
logger.debug("Successfully decoded JWT userinfo with claims: {}", claims.keys)

// Create OidcUserInfo and OidcUser with the decoded claims
val userInfo = OidcUserInfo(claims)
return DefaultOidcUser(
emptyList<GrantedAuthority>(),
userRequest.idToken,
userInfo,
)
}

private fun decodeJwtClaims(jwtToken: String): Map<String, Any> {
val parts = jwtToken.split(".")
if (parts.size != 3) {

Check warning on line 123 in backend/src/main/kotlin/fr/gouv/cacem/monitorenv/config/SecurityConfig.kt

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Replace this if expression with require(parts.size == 3) { "Invalid JWT format: expected 3 parts, got ${parts.size}" }.

See more on https://sonarcloud.io/project/issues?id=MTES-MCT_monitorenv&issues=AZrfEBxJUYIu91RCY606&open=AZrfEBxJUYIu91RCY606&pullRequest=2638
throw IllegalArgumentException("Invalid JWT format: expected 3 parts, got ${parts.size}")
}

val payloadBase64 = parts[1]
val decodedBytes = Base64.getUrlDecoder().decode(payloadBase64)
val payloadJson = String(decodedBytes, Charsets.UTF_8)

@Suppress("UNCHECKED_CAST")
return objectMapper.readValue(payloadJson, LinkedHashMap::class.java) as Map<String, Any>
}

private fun validateAndProcessUser(oidcUser: OidcUser): OidcUser {

if (oidcProperties.bypassEmailDomainsFilter == "true") {
logger.info("✅ OIDC is bypassing email domain checks.")
return oidcUser
} catch (e: Exception) {
logger.error("⛔ Exception in loadUser", e)
throw e
}

val emailClaim = oidcUser.claims["email"] as? String
logger.debug("User email from JWT: $emailClaim")

if (emailClaim.isNullOrBlank()) {
val errorMsg = "Email claim is missing or empty in JWT"
logger.error("❌ $errorMsg")
throw OAuth2AuthenticationException(errorMsg)
}

val emailDomain = emailClaim.substringAfterLast("@")

val isAuthorized =
oidcProperties.authorizedEmailDomains.any { domain ->
emailDomain.equals(domain, ignoreCase = true)
}

if (!isAuthorized) {
val errorMsg =
"User not authorized. Email domain '$emailDomain' does not match any authorized domain: ${oidcProperties.authorizedEmailDomains}"
logger.error("❌ $errorMsg")
throw OAuth2AuthenticationException(errorMsg)
}

logger.info("✅ User authorized with email domain: $emailDomain")
return oidcUser
}
}
}
Expand Down Expand Up @@ -100,14 +195,13 @@

authorize.requestMatchers("/**").permitAll()
} else {
logger.info(
logger.warn(
"""
✅ OIDC Authentication is enabled.
""".trimIndent(),
)

authorize
// Autorise tout le monde sur ces routes (ex: statiques, version, health)
.requestMatchers(
"/",
*FRONTEND_APP_ROUTES.toTypedArray(),
Expand Down Expand Up @@ -139,6 +233,7 @@
.authenticated()
}
}

if (oidcProperties.enabled == true && clientRegistrationRepository != null) {
http
.oauth2Login { oauth2 ->
Expand All @@ -164,7 +259,7 @@
@Bean
@ConditionalOnProperty(value = ["monitorenv.oidc.enabled"], havingValue = "true")
fun successHandler(): AuthenticationSuccessHandler {
println("Redirect URL is: '${oidcProperties.successUrl}'")
logger.info("Redirect URL is: '${oidcProperties.successUrl}'")
return SimpleUrlAuthenticationSuccessHandler(oidcProperties.successUrl)
}

Expand All @@ -177,7 +272,19 @@
response: HttpServletResponse,
exception: AuthenticationException,
) {
logger.error("Authentication failed: ${exception.message}", exception)
val errorMessage =
exception.message
?: exception.cause?.message
?: exception.javaClass.simpleName

logger.error("❌ Authentication failed: $errorMessage", exception)

// Log the full exception chain for debugging
if (exception.cause != null) {
logger.error(
"Caused by: ${exception.cause?.javaClass?.simpleName} - ${exception.cause?.message}",
)
}

super.onAuthenticationFailure(request, response, exception)
}
Expand All @@ -192,8 +299,10 @@
allowedHeaders = listOf("Authorization", "Cache-Control", "Content-Type")
allowCredentials = true
}

val source = UrlBasedCorsConfigurationSource()
source.registerCorsConfiguration("/**", configuration)

return source
}
}
Loading
Loading