+
(
"JWKS URL"
)
+ data object SsoLogoutUrl : ConfigProperties(
+ String::class,
+ "sso.oidc.logout-url",
+ "Logout URL"
+ )
+
data object SsoMatchExistingUsersBy : ConfigProperties(
MatchUsersBy::class,
"sso.oidc.match-existing-users-by",
diff --git a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt
index bb528b7..e3b3234 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/config/ConfigService.kt
@@ -50,10 +50,9 @@ class ConfigService(
* Used internally.
*
* @param configProperty: The config property containing necessary type information
- * @return The current value if set or the default value
- * @throws IllegalArgumentException if no value is set and no default value exists
+ * @return The current value if set or the default value or null if no value is set and no default value exists
*/
- fun getConfigValue(configProperty: ConfigProperties): T {
+ fun getConfigValue(configProperty: ConfigProperties): T? {
log.info { "Getting config value '${configProperty.key}'" }
@@ -61,7 +60,7 @@ class ConfigService(
return if (appConfig != null) {
getValue(appConfig.value, configProperty)
} else {
- configProperty.default ?: throw IllegalArgumentException("No value found for key: ${configProperty.key}")
+ configProperty.default ?: return null
}
}
@@ -70,10 +69,9 @@ class ConfigService(
* Used for the external API.
*
* @param key: The key of the config property
- * @return The current value if set or the default value
- * @throws IllegalArgumentException if no value is set and no default value exists
+ * @return The current value if set or the default value or null if no value is set and no default value exists
*/
- fun getConfigValue(key: String): String {
+ fun getConfigValue(key: String): String? {
log.info { "Getting config value '$key'" }
@@ -83,11 +81,22 @@ class ConfigService(
return if (appConfig != null) {
getValue(appConfig.value, configProperty).toString()
} else {
- configProperty.default?.toString()
- ?: throw IllegalArgumentException("No value found for key: ${configProperty.key}")
+ configProperty.default?.toString() ?: return null
}
}
+
+ /**
+ * Set the value for a specified key in a type-safe way.
+ *
+ * @param configProperty: The target config property
+ * @param value: Value to set the config property to
+ * @throws IllegalArgumentException if the value can't be cast to the type defined for the config property
+ */
+ fun setConfigValue(configProperty: ConfigProperties, value: T) {
+ return setConfigValue(configProperty.key, value)
+ }
+
/**
* Set the value for a specified key.
* Checks if the value can be cast to the type defined for the config property.
diff --git a/src/main/kotlin/de/grimsi/gameyfin/meta/SecurityConfig.kt b/src/main/kotlin/de/grimsi/gameyfin/meta/SecurityConfig.kt
index bf17bf8..9a8f053 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/meta/SecurityConfig.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/meta/SecurityConfig.kt
@@ -1,28 +1,36 @@
package de.grimsi.gameyfin.meta
import com.vaadin.flow.spring.security.VaadinWebSecurity
+import de.grimsi.gameyfin.config.ConfigProperties
+import de.grimsi.gameyfin.config.ConfigService
import org.springframework.context.annotation.Bean
+import org.springframework.context.annotation.Conditional
import org.springframework.context.annotation.Configuration
import org.springframework.core.env.Environment
+import org.springframework.http.HttpStatus
import org.springframework.security.config.annotation.web.builders.HttpSecurity
import org.springframework.security.config.annotation.web.builders.WebSecurity
import org.springframework.security.config.annotation.web.configuration.EnableWebSecurity
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer
+import org.springframework.security.config.http.SessionCreationPolicy
import org.springframework.security.core.session.SessionRegistry
import org.springframework.security.core.session.SessionRegistryImpl
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.security.oauth2.client.registration.ClientRegistration
+import org.springframework.security.oauth2.client.registration.ClientRegistrationRepository
+import org.springframework.security.oauth2.client.registration.InMemoryClientRegistrationRepository
+import org.springframework.security.oauth2.core.AuthorizationGrantType
+import org.springframework.security.web.authentication.logout.HttpStatusReturningLogoutSuccessHandler
@Configuration
@EnableWebSecurity
class SecurityConfig(
- private val environment: Environment
+ private val environment: Environment,
+ private val config: ConfigService
) : VaadinWebSecurity() {
- @Bean
- fun sessionRegistry(): SessionRegistry {
- return SessionRegistryImpl()
- }
+ private val ssoProviderKey: String = "oidc"
@Throws(Exception::class)
override fun configure(http: HttpSecurity) {
@@ -35,12 +43,19 @@ class SecurityConfig(
http.sessionManagement { sessionManagement ->
sessionManagement
+ .sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
.maximumSessions(3)
.sessionRegistry(sessionRegistry())
}
super.configure(http)
- setLoginView(http, "/login")
+
+ if (config.getConfigValue(ConfigProperties.SsoEnabled) == true) {
+ setOAuth2LoginPage(http, "/oauth2/authorization/$ssoProviderKey")
+ http.logout { logout -> logout.logoutSuccessHandler((HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))) }
+ } else {
+ setLoginView(http, "/login")
+ }
}
@Throws(Exception::class)
@@ -52,6 +67,32 @@ class SecurityConfig(
}
}
+ @Bean
+ fun sessionRegistry(): SessionRegistry {
+ return SessionRegistryImpl()
+ }
+
+ // TODO: Maybe switch to a database-backed client registration repository? Not sure if worth it.
+ @Bean
+ @Conditional(SsoEnabledCondition::class)
+ fun clientRegistrationRepository(): ClientRegistrationRepository? {
+ val clientRegistration = ClientRegistration.withRegistrationId(ssoProviderKey)
+ .clientId(config.getConfigValue(ConfigProperties.SsoClientId))
+ .clientSecret(config.getConfigValue(ConfigProperties.SsoClientSecret))
+ .scope("openid", "profile", "email")
+ .userNameAttributeName("preferred_username")
+ .authorizationGrantType(AuthorizationGrantType.AUTHORIZATION_CODE)
+ .issuerUri(config.getConfigValue(ConfigProperties.SsoIssuerUrl))
+ .authorizationUri(config.getConfigValue(ConfigProperties.SsoAuthorizeUrl))
+ .tokenUri(config.getConfigValue(ConfigProperties.SsoTokenUrl))
+ .userInfoUri(config.getConfigValue(ConfigProperties.SsoUserInfoUrl))
+ .jwkSetUri(config.getConfigValue(ConfigProperties.SsoJwksUrl))
+ .redirectUri("{baseUrl}/login/oauth2/code/{registrationId}")
+ .build()
+
+ return InMemoryClientRegistrationRepository(clientRegistration)
+ }
+
@Bean
fun passwordEncoder(): PasswordEncoder {
return BCryptPasswordEncoder()
diff --git a/src/main/kotlin/de/grimsi/gameyfin/meta/SsoEnabledCondition.kt b/src/main/kotlin/de/grimsi/gameyfin/meta/SsoEnabledCondition.kt
new file mode 100644
index 0000000..6bcaa99
--- /dev/null
+++ b/src/main/kotlin/de/grimsi/gameyfin/meta/SsoEnabledCondition.kt
@@ -0,0 +1,38 @@
+package de.grimsi.gameyfin.meta
+
+import de.grimsi.gameyfin.config.ConfigProperties
+import org.springframework.context.annotation.Condition
+import org.springframework.context.annotation.ConditionContext
+import org.springframework.core.env.Environment
+import org.springframework.core.type.AnnotatedTypeMetadata
+import java.sql.DriverManager
+
+/**
+ * Since we are loading this config so early the Spring context has not fully loaded yet, not even a DataSource.
+ * Thankfully the environment is already available, and we can use it to connect to the database.
+ * So we are rawdogging the database connection and query execution here.
+ */
+class SsoEnabledCondition : Condition {
+ override fun matches(context: ConditionContext, metadata: AnnotatedTypeMetadata): Boolean {
+ try {
+ val environment = context.beanFactory!!.getBean(Environment::class.java);
+ val url = environment.getProperty("spring.datasource.url");
+ val user = environment.getProperty("spring.datasource.username");
+ val password = environment.getProperty("spring.datasource.password");
+ val connection = DriverManager.getConnection(url, user, password);
+
+ connection.use { connection ->
+ val statement = connection.prepareStatement("SELECT \"value\" FROM app_config WHERE \"key\" = ?")
+ statement.setString(1, ConfigProperties.SsoEnabled.key)
+ val resultSet = statement.executeQuery()
+ if (resultSet.next()) {
+ return resultSet.getBoolean("value")
+ }
+ }
+ } catch (_: Exception) {
+ return false
+ }
+
+ return false
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/DynamicAccessInterceptor.kt b/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/DynamicAccessInterceptor.kt
index 597b5aa..8178984 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/DynamicAccessInterceptor.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/meta/annotations/DynamicAccessInterceptor.kt
@@ -24,7 +24,7 @@ class DynamicAccessInterceptor(
// Check if method is annotated with @DynamicPublicAccess
if (method.isAnnotationPresent(DynamicPublicAccess::class.java)) {
// Check if user is authenticated or public access is enabled
- if (request.userPrincipal != null || configService.getConfigValue(ConfigProperties.LibraryAllowPublicAccess)) {
+ if (request.userPrincipal != null || configService.getConfigValue(ConfigProperties.LibraryAllowPublicAccess) == true) {
return true
}
diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt
index 8c0c62f..e9acd38 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt
@@ -20,7 +20,7 @@ class UserEndpoint(
@PermitAll
fun getUserInfo(): UserInfoDto {
val auth: Authentication = SecurityContextHolder.getContext().authentication
- return userService.getUserInfo(auth.name)
+ return userService.getUserInfo(auth)
}
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt
index e4d2bc0..8ce989d 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserService.kt
@@ -9,12 +9,14 @@ import de.grimsi.gameyfin.users.entities.User
import de.grimsi.gameyfin.users.persistence.AvatarContentStore
import de.grimsi.gameyfin.users.persistence.UserRepository
import jakarta.transaction.Transactional
+import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
import org.springframework.security.crypto.password.PasswordEncoder
+import org.springframework.security.oauth2.core.oidc.user.OidcUser
import org.springframework.stereotype.Service
import org.springframework.web.multipart.MultipartFile
import java.io.InputStream
@@ -50,8 +52,14 @@ class UserService(
return userRepository.findAll().map { u -> toUserInfo(u) }
}
- fun getUserInfo(username: String): UserInfoDto {
- val user = userByUsername(username)
+ fun getUserInfo(auth: Authentication): UserInfoDto {
+ val principal = auth.principal
+
+ if (principal is OidcUser) {
+ return toUserInfo(User(principal))
+ }
+
+ val user = userByUsername(auth.name)
return toUserInfo(user)
}
@@ -120,6 +128,7 @@ class UserService(
return UserInfoDto(
username = user.username,
email = user.email,
+ managedBySso = user.oidcProviderId != null,
roles = user.roles.map { r -> r.rolename }
)
}
diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt
index 5ccb9e5..982373f 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/users/dto/UserInfoDto.kt
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.users.dto
data class UserInfoDto(
val username: String,
+ val managedBySso: Boolean,
val email: String,
val roles: List
)
\ No newline at end of file
diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt b/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt
index 1a3f80e..beae61f 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/users/entities/User.kt
@@ -1,8 +1,10 @@
package de.grimsi.gameyfin.users.entities
+import de.grimsi.gameyfin.meta.Roles
import jakarta.annotation.Nullable
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
+import org.springframework.security.oauth2.core.oidc.user.OidcUser
@Entity
@@ -39,4 +41,14 @@ class User(
inverseJoinColumns = [JoinColumn(name = "role_id", referencedColumnName = "id")]
)
var roles: Collection = emptyList()
-)
\ No newline at end of file
+) {
+
+ constructor(oidcUser: OidcUser) : this(
+ username = oidcUser.preferredUsername,
+ email = oidcUser.email,
+ oidcProviderId = oidcUser.subject
+ ) {
+ // FIXME: Implement role mapping from OIDC provider
+ this.roles = listOf(Role(Roles.ADMIN.roleName))
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/persistence/UserRepository.kt b/src/main/kotlin/de/grimsi/gameyfin/users/persistence/UserRepository.kt
index 5aca5e3..53913e3 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/users/persistence/UserRepository.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/users/persistence/UserRepository.kt
@@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository
interface UserRepository : JpaRepository {
fun findByUsername(userName: String): User?
+ fun findByOidcProviderId(oidcProviderId: String): User?
}
\ No newline at end of file