From 23458f6e0075aac40dfd27bab8d94794da17330d Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Horv=C3=A1th=20Istv=C3=A1n?= Date: Tue, 17 Dec 2024 20:42:17 +0100 Subject: [PATCH] Checkpoint --- backend/build.gradle.kts | 1 + .../component/login/AuthschLoginController.kt | 34 +++++++----- .../hu/bme/sch/cmsch/config/SecurityConfig.kt | 30 ++++++++--- .../hu/bme/sch/cmsch/jwt/JwtTokenFilter.kt | 2 +- .../sch/cmsch/model/Oauth2AuthorizedClient.kt | 52 +++++++++++++++++++ .../cmsch/model/Oauth2AuthorizedClientId.kt | 39 ++++++++++++++ .../hu/bme/sch/cmsch/model/SpringSession.kt | 42 +++++++++++++++ .../sch/cmsch/model/SpringSessionAttribute.kt | 23 ++++++++ .../cmsch/model/SpringSessionAttributeId.kt | 36 +++++++++++++ 9 files changed, 238 insertions(+), 21 deletions(-) create mode 100644 backend/src/main/kotlin/hu/bme/sch/cmsch/model/Oauth2AuthorizedClient.kt create mode 100644 backend/src/main/kotlin/hu/bme/sch/cmsch/model/Oauth2AuthorizedClientId.kt create mode 100644 backend/src/main/kotlin/hu/bme/sch/cmsch/model/SpringSession.kt create mode 100644 backend/src/main/kotlin/hu/bme/sch/cmsch/model/SpringSessionAttribute.kt create mode 100644 backend/src/main/kotlin/hu/bme/sch/cmsch/model/SpringSessionAttributeId.kt diff --git a/backend/build.gradle.kts b/backend/build.gradle.kts index cb1d51ae..7b0fb7e5 100644 --- a/backend/build.gradle.kts +++ b/backend/build.gradle.kts @@ -41,6 +41,7 @@ dependencies { implementation("jakarta.xml.bind:jakarta.xml.bind-api:4.0.2") api("org.springframework.boot:spring-boot-configuration-processor") api("org.springframework.boot:spring-boot-starter-data-jpa") + api("org.springframework.session:spring-session-jdbc") api("org.springframework.boot:spring-boot-starter-oauth2-client") api("org.springframework.boot:spring-boot-starter-security") api("org.springframework.boot:spring-boot-starter-web") diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/AuthschLoginController.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/AuthschLoginController.kt index c3a425ab..5b5ef71f 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/AuthschLoginController.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/component/login/AuthschLoginController.kt @@ -50,11 +50,11 @@ class AuthschLoginController( } @GetMapping("/control/logout") - fun logout(auth: Authentication?, httpResponse: HttpServletResponse): String { + fun logout(auth: Authentication?, response: HttpServletResponse): String { log.info("Logging out from user {}", auth?.getUserOrNull()?.internalId ?: "n/a") try { - httpResponse.addCookie(createJwtCookie(null).apply { maxAge = 0 }) + createJwtCookies(null).forEach { response.addCookie(it) } SecurityContextHolder.getContext().authentication = null } catch (e: Exception) { // It should be logged out anyway @@ -70,8 +70,7 @@ class AuthschLoginController( return "redirect:${applicationComponent.siteUrl.getValue()}?error=cannot-generate-jwt" } val jwtToken = jwtTokenProvider.createToken(auth.principal as CmschUser) - - response.addCookie(createJwtCookie(jwtToken)) + createJwtCookies(jwtToken).forEach { response.addCookie(it) } return "redirect:${applicationComponent.siteUrl.getValue()}" } @@ -84,7 +83,7 @@ class AuthschLoginController( if (auth == null) return ResponseEntity.status(HttpStatus.UNAUTHORIZED).build() - response.addCookie(createJwtCookie(jwtTokenProvider.refreshToken(auth))) + createJwtCookies(jwtTokenProvider.refreshToken(auth)).forEach { response.addCookie(it) } return ResponseEntity.ok().build() } @@ -93,13 +92,22 @@ class AuthschLoginController( return URI(url).host } - private fun createJwtCookie(value: String?): Cookie { - return Cookie("jwt", value).apply { - isHttpOnly = true - path = "/" - maxAge = startupPropertyConfig.sessionValidityInMilliseconds.toInt() / 1000 - secure = true - domain = getDomainFromUrl(applicationComponent.siteUrl.getValue()) - } + private fun createJwtCookies(value: String?): List { + return listOf( + Cookie("jwt", value).apply { + isHttpOnly = true + path = "/" + maxAge = startupPropertyConfig.sessionValidityInMilliseconds.toInt() / 1000 + secure = true + domain = getDomainFromUrl(applicationComponent.siteUrl.getValue()) + }, + Cookie("jwt", value).apply { + isHttpOnly = true + path = "/" + maxAge = startupPropertyConfig.sessionValidityInMilliseconds.toInt() / 1000 + secure = true + domain = getDomainFromUrl(applicationComponent.adminSiteUrl.getValue()) + }, + ) } } diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/SecurityConfig.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/SecurityConfig.kt index 75b8d145..6512a327 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/config/SecurityConfig.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/config/SecurityConfig.kt @@ -21,12 +21,15 @@ import org.springframework.context.annotation.Configuration import org.springframework.core.Ordered import org.springframework.http.HttpHeaders import org.springframework.http.MediaType +import org.springframework.jdbc.core.JdbcTemplate import org.springframework.retry.annotation.EnableRetry import org.springframework.security.config.Customizer import org.springframework.security.config.annotation.web.builders.HttpSecurity import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity import org.springframework.security.config.http.SessionCreationPolicy import org.springframework.security.core.authority.SimpleGrantedAuthority +import org.springframework.security.oauth2.client.JdbcOAuth2AuthorizedClientService +import org.springframework.security.oauth2.client.OAuth2AuthorizedClientService import org.springframework.security.oauth2.client.oidc.userinfo.OidcUserRequest import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository import org.springframework.security.oauth2.client.userinfo.OAuth2UserRequest @@ -34,11 +37,13 @@ import org.springframework.security.oauth2.core.oidc.user.DefaultOidcUser import org.springframework.security.oauth2.core.user.DefaultOAuth2User import org.springframework.security.web.SecurityFilterChain import org.springframework.security.web.util.matcher.AntPathRequestMatcher.antMatcher +import org.springframework.session.jdbc.config.annotation.web.http.EnableJdbcHttpSession import org.springframework.web.reactive.function.client.WebClient import java.util.* -@EnableWebSecurity @Configuration +@EnableJdbcHttpSession +@EnableWebSecurity @EnableRetry(order = Ordered.HIGHEST_PRECEDENCE) @ConditionalOnBean(LoginComponent::class) open class SecurityConfig( @@ -66,8 +71,18 @@ open class SecurityConfig( .defaultHeader(HttpHeaders.USER_AGENT, "AuthSchKotlinAPI") .build() + @Bean - fun securityFilterChain(http: HttpSecurity): SecurityFilterChain { + fun authorizedClientService( + jdbcTemplate: JdbcTemplate, + clientRegistrationRepository: ClientRegistrationRepository + ): OAuth2AuthorizedClientService = JdbcOAuth2AuthorizedClientService(jdbcTemplate, clientRegistrationRepository) + + @Bean + fun securityFilterChain( + http: HttpSecurity, + authorizedClientService: OAuth2AuthorizedClientService + ): SecurityFilterChain { http.authorizeHttpRequests { it.requestMatchers( antMatcher("/"), @@ -130,7 +145,6 @@ open class SecurityConfig( RoleType.ADMIN.name, RoleType.SUPERUSER.name ) - it.requestMatchers( antMatcher("/admin/**"), antMatcher("/cdn/**") @@ -143,16 +157,18 @@ open class SecurityConfig( http.formLogin { it.disable() } http.exceptionHandling { it.accessDeniedPage("/403") } http.with(JwtConfigurer(jwtTokenProvider), Customizer.withDefaults()) - http.sessionManagement { it.sessionCreationPolicy(SessionCreationPolicy.STATELESS) } http.oauth2Login { oauth2 -> - oauth2.loginPage("/oauth2/authorization") + oauth2 + .loginPage("/oauth2/authorization") .authorizationEndpoint { it.authorizationRequestResolver( CustomAuthorizationRequestResolver( clientRegistrationRepository, "/oauth2/authorization", loginComponent ) ) - }.userInfoEndpoint { userInfo -> + } + .authorizedClientService(authorizedClientService) + .userInfoEndpoint { userInfo -> userInfo .oidcUserService { if (it.clientRegistration.clientId.contains("google")) { @@ -212,7 +228,7 @@ open class SecurityConfig( private fun resolveKeycloakUser(request: OidcUserRequest): DefaultOidcUser { val decodedPayload = String(Base64.getDecoder().decode(request.accessToken.tokenValue.split(".")[1])) val profile: KeycloakUserInfoResponse = objectMapper.readerFor(KeycloakUserInfoResponse::class.java) - .readValue(decodedPayload) + .readValue(decodedPayload) val userEntity = authschLoginService.fetchKeycloakUserEntity(profile) auditLogService.login(userEntity, "keycloak user login g:${userEntity.group} r:${userEntity.role}") diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/jwt/JwtTokenFilter.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/jwt/JwtTokenFilter.kt index dd75424a..7919655f 100644 --- a/backend/src/main/kotlin/hu/bme/sch/cmsch/jwt/JwtTokenFilter.kt +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/jwt/JwtTokenFilter.kt @@ -24,7 +24,7 @@ class JwtTokenFilter( val httpRequest = req as HttpServletRequest val token: String? = jwtTokenProvider.resolveToken(httpRequest) try { - if (token != null && jwtTokenProvider.validateToken(token)) { + if (!token.isNullOrBlank() && jwtTokenProvider.validateToken(token)) { val auth: Authentication = jwtTokenProvider.getAuthentication(token) SecurityContextHolder.getContext().authentication = auth } diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/model/Oauth2AuthorizedClient.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/model/Oauth2AuthorizedClient.kt new file mode 100644 index 00000000..e3ab6cb1 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/model/Oauth2AuthorizedClient.kt @@ -0,0 +1,52 @@ +package hu.bme.sch.cmsch.model + +import jakarta.persistence.* +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.hibernate.annotations.ColumnDefault +import java.time.Instant + +/** + * Created for JdbcOAuth2AuthorizedClientService + */ +@Entity +@Table(name = "OAUTH2_AUTHORIZED_CLIENT") +open class Oauth2AuthorizedClient { + @EmbeddedId + open var id: Oauth2AuthorizedClientId? = null + + @Size(max = 100) + @NotNull + @Column(name = "ACCESS_TOKEN_TYPE", nullable = false, length = 100) + open var accessTokenType: String? = null + + @NotNull + @Column(name = "ACCESS_TOKEN_ISSUED_AT", nullable = false) + open var accessTokenIssuedAt: Instant? = null + + @NotNull + @Column(name = "ACCESS_TOKEN_EXPIRES_AT", nullable = false) + open var accessTokenExpiresAt: Instant? = null + + @Size(max = 1000) + @ColumnDefault("NULL") + @Column(name = "ACCESS_TOKEN_SCOPES", length = 1000) + open var accessTokenScopes: String? = null + + @ColumnDefault("NULL") + @Column(name = "REFRESH_TOKEN_ISSUED_AT") + open var refreshTokenIssuedAt: Instant? = null + + @NotNull + @ColumnDefault("CURRENT_TIMESTAMP") + @Column(name = "CREATED_AT", nullable = false) + open var createdAt: Instant? = null + + @Column(name = "ACCESS_TOKEN_VALUE", length = Integer.MAX_VALUE) + open var accessTokenValue: ByteArray? = null + + @ColumnDefault("NULL") + @Column(name = "REFRESH_TOKEN_VALUE", length = Integer.MAX_VALUE) + open var refreshTokenValue: ByteArray? = null + +} diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/model/Oauth2AuthorizedClientId.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/model/Oauth2AuthorizedClientId.kt new file mode 100644 index 00000000..83eefcb2 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/model/Oauth2AuthorizedClientId.kt @@ -0,0 +1,39 @@ +package hu.bme.sch.cmsch.model + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.hibernate.Hibernate +import java.io.Serializable +import java.util.* + +/** + * Created for JdbcOAuth2AuthorizedClientService + */ +@Embeddable +open class Oauth2AuthorizedClientId : Serializable { + @Size(max = 100) + @NotNull + @Column(name = "CLIENT_REGISTRATION_ID", nullable = false, length = 100) + open var clientRegistrationId: String? = null + + @Size(max = 200) + @NotNull + @Column(name = "PRINCIPAL_NAME", nullable = false, length = 200) + open var principalName: String? = null + override fun hashCode(): Int = Objects.hash(clientRegistrationId, principalName) + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || Hibernate.getClass(this) != Hibernate.getClass(other)) return false + + other as Oauth2AuthorizedClientId + + return clientRegistrationId == other.clientRegistrationId && + principalName == other.principalName + } + + companion object { + private const val serialVersionUID = 0L + } +} diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/model/SpringSession.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/model/SpringSession.kt new file mode 100644 index 00000000..47c715a5 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/model/SpringSession.kt @@ -0,0 +1,42 @@ +package hu.bme.sch.cmsch.model + +import jakarta.persistence.Column +import jakarta.persistence.Entity +import jakarta.persistence.Id +import jakarta.persistence.Table +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size + +@Entity +@Table(name = "SPRING_SESSION") +open class SpringSession { + @Id + @Size(max = 36) + @Column(name = "PRIMARY_ID", nullable = false, length = 36) + open var primaryId: String? = null + + @Size(max = 36) + @NotNull + @Column(name = "SESSION_ID", nullable = false, length = 36) + open var sessionId: String? = null + + @NotNull + @Column(name = "CREATION_TIME", nullable = false) + open var creationTime: Long? = null + + @NotNull + @Column(name = "LAST_ACCESS_TIME", nullable = false) + open var lastAccessTime: Long? = null + + @NotNull + @Column(name = "MAX_INACTIVE_INTERVAL", nullable = false) + open var maxInactiveInterval: Int? = null + + @NotNull + @Column(name = "EXPIRY_TIME", nullable = false) + open var expiryTime: Long? = null + + @Size(max = 100) + @Column(name = "PRINCIPAL_NAME", length = 100) + open var principalName: String? = null +} diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/model/SpringSessionAttribute.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/model/SpringSessionAttribute.kt new file mode 100644 index 00000000..837abdf6 --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/model/SpringSessionAttribute.kt @@ -0,0 +1,23 @@ +package hu.bme.sch.cmsch.model + +import jakarta.persistence.* +import jakarta.validation.constraints.NotNull +import org.hibernate.annotations.OnDelete +import org.hibernate.annotations.OnDeleteAction + +@Entity +@Table(name = "SPRING_SESSION_ATTRIBUTES") +open class SpringSessionAttribute { + @EmbeddedId + open var id: SpringSessionAttributeId? = null + + @MapsId("sessionPrimaryId") + @ManyToOne(fetch = FetchType.LAZY, optional = false) + @OnDelete(action = OnDeleteAction.CASCADE) + @JoinColumn(name = "SESSION_PRIMARY_ID", nullable = false) + open var sessionPrimary: SpringSession? = null + + @NotNull + @Column(name = "ATTRIBUTE_BYTES", nullable = false, length = Integer.MAX_VALUE) + open var attributeBytes: ByteArray? = null +} diff --git a/backend/src/main/kotlin/hu/bme/sch/cmsch/model/SpringSessionAttributeId.kt b/backend/src/main/kotlin/hu/bme/sch/cmsch/model/SpringSessionAttributeId.kt new file mode 100644 index 00000000..afb7d7ed --- /dev/null +++ b/backend/src/main/kotlin/hu/bme/sch/cmsch/model/SpringSessionAttributeId.kt @@ -0,0 +1,36 @@ +package hu.bme.sch.cmsch.model + +import jakarta.persistence.Column +import jakarta.persistence.Embeddable +import jakarta.validation.constraints.NotNull +import jakarta.validation.constraints.Size +import org.hibernate.Hibernate +import java.io.Serializable +import java.util.* + +@Embeddable +open class SpringSessionAttributeId : Serializable { + @Size(max = 36) + @NotNull + @Column(name = "SESSION_PRIMARY_ID", nullable = false, length = 36) + open var sessionPrimaryId: String? = null + + @Size(max = 200) + @NotNull + @Column(name = "ATTRIBUTE_NAME", nullable = false, length = 200) + open var attributeName: String? = null + override fun hashCode(): Int = Objects.hash(sessionPrimaryId, attributeName) + override fun equals(other: Any?): Boolean { + if (this === other) return true + if (other == null || Hibernate.getClass(this) != Hibernate.getClass(other)) return false + + other as SpringSessionAttributeId + + return sessionPrimaryId == other.sessionPrimaryId && + attributeName == other.attributeName + } + + companion object { + private const val serialVersionUID = 0L + } +}