diff --git a/.run/UI debug.run.xml b/.run/UI debug.run.xml index ccfbe55..1a2afd9 100644 --- a/.run/UI debug.run.xml +++ b/.run/UI debug.run.xml @@ -1,5 +1,5 @@ - + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index e17097d..e804475 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -37,7 +37,6 @@ dependencies { // Spring Boot & Kotlin implementation("org.springframework.boot:spring-boot-starter-actuator") implementation("org.springframework.boot:spring-boot-starter-security") - implementation("org.springframework.boot:spring-boot-starter-oauth2-client") implementation("org.springframework.boot:spring-boot-starter-validation") implementation("jakarta.validation:jakarta.validation-api:3.0.2") implementation("org.jetbrains.kotlin:kotlin-reflect") @@ -56,6 +55,11 @@ dependencies { implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.14") implementation("org.springframework.cloud:spring-cloud-starter") + // SSO + implementation("org.springframework.boot:spring-boot-starter-oauth2-client") + implementation("org.springframework.boot:spring-boot-starter-oauth2-resource-server") + implementation("org.springframework.security:spring-security-oauth2-jose") + // Development developmentOnly("org.springframework.boot:spring-boot-devtools") annotationProcessor("org.springframework.boot:spring-boot-configuration-processor") diff --git a/src/main/frontend/components/ProfileMenu.tsx b/src/main/frontend/components/ProfileMenu.tsx index 7117845..559e8be 100644 --- a/src/main/frontend/components/ProfileMenu.tsx +++ b/src/main/frontend/components/ProfileMenu.tsx @@ -2,11 +2,20 @@ import {useAuth} from "Frontend/util/auth"; import {GearFine, Question, SignOut, User} from "@phosphor-icons/react"; import {Avatar, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@nextui-org/react"; import {useNavigate} from "react-router-dom"; +import {ConfigController} from "Frontend/generated/endpoints"; export default function ProfileMenu() { const auth = useAuth(); const navigate = useNavigate(); + async function logout() { + if (auth.state.user?.managedBySso) { + window.location.href = await ConfigController.getLogoutUrl() || "/"; + } else { + await auth.logout(); + } + } + const profileMenuItems = [ { label: "My Profile", @@ -27,7 +36,7 @@ export default function ProfileMenu() { { label: "Sign Out", icon: , - onClick: () => auth.logout(), + onClick: logout, color: "primary" }, ]; diff --git a/src/main/frontend/components/administration/ProfileManagement.tsx b/src/main/frontend/components/administration/ProfileManagement.tsx index a5fec30..08facb9 100644 --- a/src/main/frontend/components/administration/ProfileManagement.tsx +++ b/src/main/frontend/components/administration/ProfileManagement.tsx @@ -77,6 +77,8 @@ export default function ProfileManagement() {

My Profile

+ {auth.state.user?.managedBySso && +

Your account is managed externally.

}
{formik.values.newPassword.length > 0 && @@ -88,7 +90,7 @@ export default function ProfileManagement() {
- + - +
- - + +
+ autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/> + autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
diff --git a/src/main/frontend/components/administration/SsoManagement.tsx b/src/main/frontend/components/administration/SsoManagement.tsx index 6ed231e..7d5935a 100644 --- a/src/main/frontend/components/administration/SsoManagement.tsx +++ b/src/main/frontend/components/administration/SsoManagement.tsx @@ -1,11 +1,75 @@ import React from "react"; import withConfigPage from "Frontend/components/administration/withConfigPage"; import * as Yup from 'yup'; +import ConfigFormField from "Frontend/components/administration/ConfigFormField"; +import Section from "Frontend/components/general/Section"; +import {Button} from "@nextui-org/react"; +import {MagicWand} from "@phosphor-icons/react"; +import {toast} from "sonner"; function SsoMangementLayout({getConfig, formik}: any) { + + function isAutoPopulateDisabled() { + return !formik.values.sso.oidc.enabled || !formik.values.sso.oidc["issuer-url"]; + } + + async function autoPopulate() { + let issuerUrl: string = formik.values.sso.oidc["issuer-url"]; + if (issuerUrl.endsWith("/")) issuerUrl = issuerUrl.slice(0, -1); + + try { + const response = await fetch(issuerUrl + "/.well-known/openid-configuration"); + const data = await response.json(); + + formik.setFieldValue("sso.oidc.authorize-url", data.authorization_endpoint); + formik.setFieldValue("sso.oidc.token-url", data.token_endpoint); + formik.setFieldValue("sso.oidc.userinfo-url", data.userinfo_endpoint); + formik.setFieldValue("sso.oidc.logout-url", data.end_session_endpoint); + formik.setFieldValue("sso.oidc.jwks-url", data.jwks_uri); + } catch (e) { + toast.error("Failed to auto-populate SSO configuration"); + } + } + return (
+
+
+ +
+
+ + +
+ +
+ + +
+ + +
+ + + + + +
+
); } diff --git a/src/main/frontend/components/administration/UserManagement.tsx b/src/main/frontend/components/administration/UserManagement.tsx index f13cc53..ac7f3e6 100644 --- a/src/main/frontend/components/administration/UserManagement.tsx +++ b/src/main/frontend/components/administration/UserManagement.tsx @@ -30,8 +30,7 @@ function UserManagementLayout({getConfig, formik}: any) { {users.map((user) => )} - ) - ; + ); } export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users"); \ No newline at end of file diff --git a/src/main/frontend/components/general/Input.tsx b/src/main/frontend/components/general/Input.tsx index d782c63..df5a6bd 100644 --- a/src/main/frontend/components/general/Input.tsx +++ b/src/main/frontend/components/general/Input.tsx @@ -9,7 +9,7 @@ const Input = ({label, ...props}) => { const [field, meta] = useField(props); return ( -
+
( "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