mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 16:20:03 +00:00
WIP: Implement SSO
This commit is contained in:
@@ -1,5 +1,5 @@
|
|||||||
<component name="ProjectRunConfigurationManager">
|
<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" />
|
<method v="2" />
|
||||||
</configuration>
|
</configuration>
|
||||||
</component>
|
</component>
|
||||||
+5
-1
@@ -37,7 +37,6 @@ dependencies {
|
|||||||
// Spring Boot & Kotlin
|
// Spring Boot & Kotlin
|
||||||
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
implementation("org.springframework.boot:spring-boot-starter-actuator")
|
||||||
implementation("org.springframework.boot:spring-boot-starter-security")
|
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("org.springframework.boot:spring-boot-starter-validation")
|
||||||
implementation("jakarta.validation:jakarta.validation-api:3.0.2")
|
implementation("jakarta.validation:jakarta.validation-api:3.0.2")
|
||||||
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
implementation("org.jetbrains.kotlin:kotlin-reflect")
|
||||||
@@ -56,6 +55,11 @@ dependencies {
|
|||||||
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.14")
|
implementation("com.github.paulcwarren:spring-content-fs-boot-starter:3.0.14")
|
||||||
implementation("org.springframework.cloud:spring-cloud-starter")
|
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
|
// Development
|
||||||
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
developmentOnly("org.springframework.boot:spring-boot-devtools")
|
||||||
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
annotationProcessor("org.springframework.boot:spring-boot-configuration-processor")
|
||||||
|
|||||||
@@ -2,11 +2,20 @@ import {useAuth} from "Frontend/util/auth";
|
|||||||
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react";
|
import {GearFine, Question, SignOut, User} from "@phosphor-icons/react";
|
||||||
import {Avatar, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@nextui-org/react";
|
import {Avatar, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger} from "@nextui-org/react";
|
||||||
import {useNavigate} from "react-router-dom";
|
import {useNavigate} from "react-router-dom";
|
||||||
|
import {ConfigController} from "Frontend/generated/endpoints";
|
||||||
|
|
||||||
export default function ProfileMenu() {
|
export default function ProfileMenu() {
|
||||||
const auth = useAuth();
|
const auth = useAuth();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
|
async function logout() {
|
||||||
|
if (auth.state.user?.managedBySso) {
|
||||||
|
window.location.href = await ConfigController.getLogoutUrl() || "/";
|
||||||
|
} else {
|
||||||
|
await auth.logout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
const profileMenuItems = [
|
const profileMenuItems = [
|
||||||
{
|
{
|
||||||
label: "My Profile",
|
label: "My Profile",
|
||||||
@@ -27,7 +36,7 @@ export default function ProfileMenu() {
|
|||||||
{
|
{
|
||||||
label: "Sign Out",
|
label: "Sign Out",
|
||||||
icon: <SignOut/>,
|
icon: <SignOut/>,
|
||||||
onClick: () => auth.logout(),
|
onClick: logout,
|
||||||
color: "primary"
|
color: "primary"
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -77,6 +77,8 @@ export default function ProfileManagement() {
|
|||||||
<Form>
|
<Form>
|
||||||
<div className="flex flex-row flex-grow justify-between mb-8">
|
<div className="flex flex-row flex-grow justify-between mb-8">
|
||||||
<h2 className="text-2xl font-bold">My Profile</h2>
|
<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">
|
<div className="flex flex-row items-center gap-4">
|
||||||
{formik.values.newPassword.length > 0 &&
|
{formik.values.newPassword.length > 0 &&
|
||||||
@@ -88,7 +90,7 @@ export default function ProfileManagement() {
|
|||||||
<Button
|
<Button
|
||||||
color="primary"
|
color="primary"
|
||||||
isLoading={formik.isSubmitting}
|
isLoading={formik.isSubmitting}
|
||||||
disabled={formik.isSubmitting || configSaved}
|
disabled={formik.isSubmitting || configSaved || auth.state.user?.managedBySso}
|
||||||
type="submit"
|
type="submit"
|
||||||
>
|
>
|
||||||
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
{formik.isSubmitting ? "" : configSaved ? <Check/> : "Save"}
|
||||||
@@ -105,24 +107,28 @@ export default function ProfileManagement() {
|
|||||||
</Avatar>
|
</Avatar>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-row gap-2">
|
<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}
|
<Button onClick={() => uploadAvatar(avatar)} isDisabled={avatar == null}
|
||||||
color="success">Upload</Button>
|
color="success">Upload</Button>
|
||||||
<Tooltip content="Remove your current avatar">
|
<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>
|
</Tooltip>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="flex flex-col flex-grow">
|
<div className="flex flex-col flex-grow">
|
||||||
<Section title="Personal information"/>
|
<Section title="Personal information"/>
|
||||||
<Input name="username" label="Username" type="text" autocomplete="username"/>
|
<Input name="username" label="Username" type="text" autocomplete="username"
|
||||||
<Input name="email" label="Email" type="email" autocomplete="email"/>
|
isDisabled={auth.state.user?.managedBySso}/>
|
||||||
|
<Input name="email" label="Email" type="email" autocomplete="email"
|
||||||
|
isDisabled={auth.state.user?.managedBySso}/>
|
||||||
<Section title="Security"/>
|
<Section title="Security"/>
|
||||||
<Input name="newPassword" label="New Password" type="password"
|
<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"
|
<Input name="passwordRepeat" label="Repeat password" type="password"
|
||||||
autocomplete="new-password"/>
|
autocomplete="new-password" isDisabled={auth.state.user?.managedBySso}/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Form>
|
</Form>
|
||||||
|
|||||||
@@ -1,11 +1,75 @@
|
|||||||
import React from "react";
|
import React from "react";
|
||||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||||
import * as Yup from 'yup';
|
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 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 (
|
return (
|
||||||
<div className="flex flex-col">
|
<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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,8 +30,7 @@ function UserManagementLayout({getConfig, formik}: any) {
|
|||||||
{users.map((user) => <UserManagementCard user={user} key={user.username}/>)}
|
{users.map((user) => <UserManagementCard user={user} key={user.username}/>)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)
|
);
|
||||||
;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users");
|
export const UserManagement = withConfigPage(UserManagementLayout, "User Management", "users");
|
||||||
@@ -9,7 +9,7 @@ const Input = ({label, ...props}) => {
|
|||||||
const [field, meta] = useField(props);
|
const [field, meta] = useField(props);
|
||||||
|
|
||||||
return (
|
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
|
<NextUiInput
|
||||||
{...props}
|
{...props}
|
||||||
{...field}
|
{...field}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package de.grimsi.gameyfin.config
|
|||||||
import com.vaadin.hilla.Endpoint
|
import com.vaadin.hilla.Endpoint
|
||||||
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
|
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
|
||||||
import de.grimsi.gameyfin.meta.Roles
|
import de.grimsi.gameyfin.meta.Roles
|
||||||
|
import jakarta.annotation.security.PermitAll
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
@@ -15,10 +16,20 @@ class ConfigController(
|
|||||||
return appConfigService.getAllConfigValues(prefix)
|
return appConfigService.getAllConfigValues(prefix)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getConfig(key: String): String {
|
fun getConfig(key: String): String? {
|
||||||
return appConfigService.getConfigValue(key)
|
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) {
|
fun setConfig(key: String, value: String) {
|
||||||
appConfigService.setConfigValue(key, value)
|
appConfigService.setConfigValue(key, value)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -104,6 +104,12 @@ sealed class ConfigProperties<T : Serializable>(
|
|||||||
"JWKS URL"
|
"JWKS URL"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data object SsoLogoutUrl : ConfigProperties<String>(
|
||||||
|
String::class,
|
||||||
|
"sso.oidc.logout-url",
|
||||||
|
"Logout URL"
|
||||||
|
)
|
||||||
|
|
||||||
data object SsoMatchExistingUsersBy : ConfigProperties<MatchUsersBy>(
|
data object SsoMatchExistingUsersBy : ConfigProperties<MatchUsersBy>(
|
||||||
MatchUsersBy::class,
|
MatchUsersBy::class,
|
||||||
"sso.oidc.match-existing-users-by",
|
"sso.oidc.match-existing-users-by",
|
||||||
|
|||||||
@@ -50,10 +50,9 @@ class ConfigService(
|
|||||||
* Used internally.
|
* Used internally.
|
||||||
*
|
*
|
||||||
* @param configProperty: The config property containing necessary type information
|
* @param configProperty: The config property containing necessary type information
|
||||||
* @return The current value if set or the default value
|
* @return The current value if set or the default value or null if no value is set and no default value exists
|
||||||
* @throws IllegalArgumentException 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}'" }
|
log.info { "Getting config value '${configProperty.key}'" }
|
||||||
|
|
||||||
@@ -61,7 +60,7 @@ class ConfigService(
|
|||||||
return if (appConfig != null) {
|
return if (appConfig != null) {
|
||||||
getValue(appConfig.value, configProperty)
|
getValue(appConfig.value, configProperty)
|
||||||
} else {
|
} 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.
|
* Used for the external API.
|
||||||
*
|
*
|
||||||
* @param key: The key of the config property
|
* @param key: The key of the config property
|
||||||
* @return The current value if set or the default value
|
* @return The current value if set or the default value or null if no value is set and no default value exists
|
||||||
* @throws IllegalArgumentException 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'" }
|
log.info { "Getting config value '$key'" }
|
||||||
|
|
||||||
@@ -83,11 +81,22 @@ class ConfigService(
|
|||||||
return if (appConfig != null) {
|
return if (appConfig != null) {
|
||||||
getValue(appConfig.value, configProperty).toString()
|
getValue(appConfig.value, configProperty).toString()
|
||||||
} else {
|
} else {
|
||||||
configProperty.default?.toString()
|
configProperty.default?.toString() ?: return null
|
||||||
?: throw IllegalArgumentException("No value found for key: ${configProperty.key}")
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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.
|
* Set the value for a specified key.
|
||||||
* Checks if the value can be cast to the type defined for the config property.
|
* Checks if the value can be cast to the type defined for the config property.
|
||||||
|
|||||||
@@ -1,28 +1,36 @@
|
|||||||
package de.grimsi.gameyfin.meta
|
package de.grimsi.gameyfin.meta
|
||||||
|
|
||||||
import com.vaadin.flow.spring.security.VaadinWebSecurity
|
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.Bean
|
||||||
|
import org.springframework.context.annotation.Conditional
|
||||||
import org.springframework.context.annotation.Configuration
|
import org.springframework.context.annotation.Configuration
|
||||||
import org.springframework.core.env.Environment
|
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.HttpSecurity
|
||||||
import org.springframework.security.config.annotation.web.builders.WebSecurity
|
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.configuration.EnableWebSecurity
|
||||||
import org.springframework.security.config.annotation.web.configurers.AuthorizeHttpRequestsConfigurer
|
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.SessionRegistry
|
||||||
import org.springframework.security.core.session.SessionRegistryImpl
|
import org.springframework.security.core.session.SessionRegistryImpl
|
||||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder
|
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
|
@Configuration
|
||||||
@EnableWebSecurity
|
@EnableWebSecurity
|
||||||
class SecurityConfig(
|
class SecurityConfig(
|
||||||
private val environment: Environment
|
private val environment: Environment,
|
||||||
|
private val config: ConfigService
|
||||||
) : VaadinWebSecurity() {
|
) : VaadinWebSecurity() {
|
||||||
|
|
||||||
@Bean
|
private val ssoProviderKey: String = "oidc"
|
||||||
fun sessionRegistry(): SessionRegistry {
|
|
||||||
return SessionRegistryImpl()
|
|
||||||
}
|
|
||||||
|
|
||||||
@Throws(Exception::class)
|
@Throws(Exception::class)
|
||||||
override fun configure(http: HttpSecurity) {
|
override fun configure(http: HttpSecurity) {
|
||||||
@@ -35,12 +43,19 @@ class SecurityConfig(
|
|||||||
|
|
||||||
http.sessionManagement { sessionManagement ->
|
http.sessionManagement { sessionManagement ->
|
||||||
sessionManagement
|
sessionManagement
|
||||||
|
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||||
.maximumSessions(3)
|
.maximumSessions(3)
|
||||||
.sessionRegistry(sessionRegistry())
|
.sessionRegistry(sessionRegistry())
|
||||||
}
|
}
|
||||||
|
|
||||||
super.configure(http)
|
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)
|
@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
|
@Bean
|
||||||
fun passwordEncoder(): PasswordEncoder {
|
fun passwordEncoder(): PasswordEncoder {
|
||||||
return BCryptPasswordEncoder()
|
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
|
// Check if method is annotated with @DynamicPublicAccess
|
||||||
if (method.isAnnotationPresent(DynamicPublicAccess::class.java)) {
|
if (method.isAnnotationPresent(DynamicPublicAccess::class.java)) {
|
||||||
// Check if user is authenticated or public access is enabled
|
// 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
|
return true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,7 +20,7 @@ class UserEndpoint(
|
|||||||
@PermitAll
|
@PermitAll
|
||||||
fun getUserInfo(): UserInfoDto {
|
fun getUserInfo(): UserInfoDto {
|
||||||
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
val auth: Authentication = SecurityContextHolder.getContext().authentication
|
||||||
return userService.getUserInfo(auth.name)
|
return userService.getUserInfo(auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
@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.AvatarContentStore
|
||||||
import de.grimsi.gameyfin.users.persistence.UserRepository
|
import de.grimsi.gameyfin.users.persistence.UserRepository
|
||||||
import jakarta.transaction.Transactional
|
import jakarta.transaction.Transactional
|
||||||
|
import org.springframework.security.core.Authentication
|
||||||
import org.springframework.security.core.GrantedAuthority
|
import org.springframework.security.core.GrantedAuthority
|
||||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||||
import org.springframework.security.core.userdetails.UserDetails
|
import org.springframework.security.core.userdetails.UserDetails
|
||||||
import org.springframework.security.core.userdetails.UserDetailsService
|
import org.springframework.security.core.userdetails.UserDetailsService
|
||||||
import org.springframework.security.core.userdetails.UsernameNotFoundException
|
import org.springframework.security.core.userdetails.UsernameNotFoundException
|
||||||
import org.springframework.security.crypto.password.PasswordEncoder
|
import org.springframework.security.crypto.password.PasswordEncoder
|
||||||
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import org.springframework.web.multipart.MultipartFile
|
import org.springframework.web.multipart.MultipartFile
|
||||||
import java.io.InputStream
|
import java.io.InputStream
|
||||||
@@ -50,8 +52,14 @@ class UserService(
|
|||||||
return userRepository.findAll().map { u -> toUserInfo(u) }
|
return userRepository.findAll().map { u -> toUserInfo(u) }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun getUserInfo(username: String): UserInfoDto {
|
fun getUserInfo(auth: Authentication): UserInfoDto {
|
||||||
val user = userByUsername(username)
|
val principal = auth.principal
|
||||||
|
|
||||||
|
if (principal is OidcUser) {
|
||||||
|
return toUserInfo(User(principal))
|
||||||
|
}
|
||||||
|
|
||||||
|
val user = userByUsername(auth.name)
|
||||||
return toUserInfo(user)
|
return toUserInfo(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -120,6 +128,7 @@ class UserService(
|
|||||||
return UserInfoDto(
|
return UserInfoDto(
|
||||||
username = user.username,
|
username = user.username,
|
||||||
email = user.email,
|
email = user.email,
|
||||||
|
managedBySso = user.oidcProviderId != null,
|
||||||
roles = user.roles.map { r -> r.rolename }
|
roles = user.roles.map { r -> r.rolename }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package de.grimsi.gameyfin.users.dto
|
|||||||
|
|
||||||
data class UserInfoDto(
|
data class UserInfoDto(
|
||||||
val username: String,
|
val username: String,
|
||||||
|
val managedBySso: Boolean,
|
||||||
val email: String,
|
val email: String,
|
||||||
val roles: List<String>
|
val roles: List<String>
|
||||||
)
|
)
|
||||||
@@ -1,8 +1,10 @@
|
|||||||
package de.grimsi.gameyfin.users.entities
|
package de.grimsi.gameyfin.users.entities
|
||||||
|
|
||||||
|
import de.grimsi.gameyfin.meta.Roles
|
||||||
import jakarta.annotation.Nullable
|
import jakarta.annotation.Nullable
|
||||||
import jakarta.persistence.*
|
import jakarta.persistence.*
|
||||||
import jakarta.validation.constraints.NotNull
|
import jakarta.validation.constraints.NotNull
|
||||||
|
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||||
|
|
||||||
|
|
||||||
@Entity
|
@Entity
|
||||||
@@ -39,4 +41,14 @@ class User(
|
|||||||
inverseJoinColumns = [JoinColumn(name = "role_id", referencedColumnName = "id")]
|
inverseJoinColumns = [JoinColumn(name = "role_id", referencedColumnName = "id")]
|
||||||
)
|
)
|
||||||
var roles: Collection<Role> = emptyList()
|
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> {
|
interface UserRepository : JpaRepository<User, Long> {
|
||||||
fun findByUsername(userName: String): User?
|
fun findByUsername(userName: String): User?
|
||||||
|
fun findByOidcProviderId(oidcProviderId: String): User?
|
||||||
}
|
}
|
||||||
Reference in New Issue
Block a user