WIP: Implement SSO

This commit is contained in:
grimsi
2024-09-16 16:27:12 +02:00
parent a2870011e8
commit 9dd641656c
18 changed files with 244 additions and 34 deletions
+1 -1
View File
@@ -1,5 +1,5 @@
<component name="ProjectRunConfigurationManager">
<configuration default="false" name="UI debug" type="JavascriptDebugType" engineId="37cae5b9-e8b2-4949-9172-aafa37fbc09c" uri="http://localhost:8080">
<configuration default="false" name="UI debug" type="JavascriptDebugType" uri="http://localhost:8080">
<method v="2" />
</configuration>
</component>
+5 -1
View File
@@ -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")
+10 -1
View File
@@ -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: <SignOut/>,
onClick: () => auth.logout(),
onClick: logout,
color: "primary"
},
];
@@ -77,6 +77,8 @@ export default function ProfileManagement() {
<Form>
<div className="flex flex-row flex-grow justify-between mb-8">
<h2 className="text-2xl font-bold">My Profile</h2>
{auth.state.user?.managedBySso &&
<p className="text-warning">Your account is managed externally.</p>}
<div className="flex flex-row items-center gap-4">
{formik.values.newPassword.length > 0 &&
@@ -88,7 +90,7 @@ export default function ProfileManagement() {
<Button
color="primary"
isLoading={formik.isSubmitting}
disabled={formik.isSubmitting || configSaved}
disabled={formik.isSubmitting || configSaved || auth.state.user?.managedBySso}
type="submit"
>
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
@@ -105,24 +107,28 @@ export default function ProfileManagement() {
</Avatar>
</div>
<div className="flex flex-row gap-2">
<NextUiInput type="file" accept="image/*" onChange={onFileSelected}/>
<NextUiInput type="file" accept="image/*" onChange={onFileSelected}
isDisabled={auth.state.user?.managedBySso}/>
<Button onClick={() => uploadAvatar(avatar)} isDisabled={avatar == null}
color="success">Upload</Button>
<Tooltip content="Remove your current avatar">
<Button onClick={removeAvatar} isIconOnly color="danger"><Trash/></Button>
<Button onClick={removeAvatar} isIconOnly color="danger"
isDisabled={auth.state.user?.managedBySso}><Trash/></Button>
</Tooltip>
</div>
</div>
<div className="flex flex-col flex-grow">
<Section title="Personal information"/>
<Input name="username" label="Username" type="text" autocomplete="username"/>
<Input name="email" label="Email" type="email" autocomplete="email"/>
<Input name="username" label="Username" type="text" autocomplete="username"
isDisabled={auth.state.user?.managedBySso}/>
<Input name="email" label="Email" type="email" autocomplete="email"
isDisabled={auth.state.user?.managedBySso}/>
<Section title="Security"/>
<Input name="newPassword" label="New Password" type="password"
autocomplete="new-password"/>
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
<Input name="passwordRepeat" label="Repeat password" type="password"
autocomplete="new-password"/>
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
</div>
</div>
</Form>
@@ -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 (
<div className="flex flex-col">
<div className="flex flex-row">
<div className="flex flex-col flex-1">
<ConfigFormField configElement={getConfig("sso.oidc.enabled")}/>
<Section title="SSO user handling"/>
<div className="flex flex-row">
<ConfigFormField configElement={getConfig("sso.oidc.auto-register-new-users")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.match-existing-users-by")}
isDisabled={!formik.values.sso.oidc.enabled}/>
</div>
<Section title="SSO provider configuration"/>
<ConfigFormField configElement={getConfig("sso.oidc.client-id")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.client-secret")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<div className="flex flex-row gap-2">
<ConfigFormField configElement={getConfig("sso.oidc.issuer-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<Button
isDisabled={isAutoPopulateDisabled()}
onPress={autoPopulate}
className="h-14 mt-2"><MagicWand/> Auto-populate</Button>
</div>
<ConfigFormField configElement={getConfig("sso.oidc.authorize-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.token-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.userinfo-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.logout-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
<ConfigFormField configElement={getConfig("sso.oidc.jwks-url")}
isDisabled={!formik.values.sso.oidc.enabled}/>
</div>
</div>
</div>
);
}
@@ -30,8 +30,7 @@ function UserManagementLayout({getConfig, formik}: any) {
{users.map((user) => <UserManagementCard user={user} key={user.username}/>)}
</div>
</div>
)
;
);
}
export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users");
@@ -9,7 +9,7 @@ const Input = ({label, ...props}) => {
const [field, meta] = useField(props);
return (
<div className="flex flex-col flex-grow max-w-sm items-start gap-2 my-2">
<div className="flex flex-col flex-grow items-start gap-2 my-2">
<NextUiInput
{...props}
{...field}
@@ -3,6 +3,7 @@ package de.grimsi.gameyfin.config
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
import de.grimsi.gameyfin.meta.Roles
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
@Endpoint
@@ -15,10 +16,20 @@ class ConfigController(
return appConfigService.getAllConfigValues(prefix)
}
fun getConfig(key: String): String {
fun getConfig(key: String): String? {
return appConfigService.getConfigValue(key)
}
@PermitAll
fun isSsoEnabled(): Boolean? {
return appConfigService.getConfigValue(ConfigProperties.SsoEnabled)
}
@PermitAll
fun getLogoutUrl(): String? {
return appConfigService.getConfigValue(ConfigProperties.SsoLogoutUrl)
}
fun setConfig(key: String, value: String) {
appConfigService.setConfigValue(key, value)
}
@@ -104,6 +104,12 @@ sealed class ConfigProperties<T : Serializable>(
"JWKS URL"
)
data object SsoLogoutUrl : ConfigProperties<String>(
String::class,
"sso.oidc.logout-url",
"Logout URL"
)
data object SsoMatchExistingUsersBy : ConfigProperties<MatchUsersBy>(
MatchUsersBy::class,
"sso.oidc.match-existing-users-by",
@@ -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 <T : Serializable> getConfigValue(configProperty: ConfigProperties<T>): T {
fun <T : Serializable> getConfigValue(configProperty: ConfigProperties<T>): 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 <T : Serializable> setConfigValue(configProperty: ConfigProperties<T>, 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.
@@ -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()
@@ -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
}
}
@@ -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
}
@@ -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)
@@ -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 }
)
}
@@ -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<String>
)
@@ -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<Role> = emptyList()
)
) {
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))
}
}
@@ -5,4 +5,5 @@ import org.springframework.data.jpa.repository.JpaRepository
interface UserRepository : JpaRepository<User, Long> {
fun findByUsername(userName: String): User?
fun findByOidcProviderId(oidcProviderId: String): User?
}