mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-15 08:15:37 +00:00
Implement role mapping from SSO
This commit is contained in:
@@ -122,7 +122,8 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
data object SsoAutoRegisterNewUsers : ConfigProperties<Boolean>(
|
||||
Boolean::class,
|
||||
"sso.oidc.auto-register-new-users",
|
||||
"Automatically create new users after registration"
|
||||
"Automatically create new users after registration",
|
||||
true
|
||||
)
|
||||
|
||||
/** Notifications */
|
||||
|
||||
@@ -1,5 +1,7 @@
|
||||
package de.grimsi.gameyfin.meta
|
||||
|
||||
import de.grimsi.gameyfin.users.RoleService.Companion.INTERNAL_ROLE_PREFIX
|
||||
|
||||
enum class Roles(val roleName: String) {
|
||||
SUPERADMIN(Names.SUPERADMIN),
|
||||
ADMIN(Names.ADMIN),
|
||||
@@ -8,9 +10,9 @@ enum class Roles(val roleName: String) {
|
||||
// necessary for the ability to use the Roles class in the @RolesAllowed annotation
|
||||
class Names {
|
||||
companion object {
|
||||
const val SUPERADMIN = "ROLE_SUPERADMIN"
|
||||
const val ADMIN = "ROLE_ADMIN"
|
||||
const val USER = "ROLE_USER"
|
||||
const val SUPERADMIN = "${INTERNAL_ROLE_PREFIX}SUPERADMIN"
|
||||
const val ADMIN = "${INTERNAL_ROLE_PREFIX}ADMIN"
|
||||
const val USER = "${INTERNAL_ROLE_PREFIX}USER"
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -85,6 +85,6 @@ class SetupDataLoader(
|
||||
fun registerUserIfNotFound(user: User, role: Roles) {
|
||||
if (userService.existsByUsername(user.username)) return
|
||||
|
||||
userService.registerUser(user, role)
|
||||
userService.registerOrUpdateUser(user, role)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,16 @@
|
||||
package de.grimsi.gameyfin.meta.security
|
||||
|
||||
import de.grimsi.gameyfin.users.RoleService
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.core.authority.mapping.GrantedAuthoritiesMapper
|
||||
|
||||
@Configuration
|
||||
class AuthorityMapperConfig(
|
||||
private val roleService: RoleService
|
||||
) {
|
||||
@Bean
|
||||
fun userAuthoritiesMapper(): GrantedAuthoritiesMapper {
|
||||
return GrantedAuthoritiesMapper { authorities -> roleService.extractGrantedAuthorities(authorities) }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.grimsi.gameyfin.meta.security
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.crypto.bcrypt.BCryptPasswordEncoder
|
||||
import org.springframework.security.crypto.password.PasswordEncoder
|
||||
|
||||
@Configuration
|
||||
class PasswordEncoderConfig {
|
||||
@Bean
|
||||
fun passwordEncoder(): PasswordEncoder {
|
||||
return BCryptPasswordEncoder()
|
||||
}
|
||||
}
|
||||
+8
-16
@@ -1,4 +1,4 @@
|
||||
package de.grimsi.gameyfin.meta
|
||||
package de.grimsi.gameyfin.meta.security
|
||||
|
||||
import com.vaadin.flow.spring.security.VaadinWebSecurity
|
||||
import de.grimsi.gameyfin.config.ConfigProperties
|
||||
@@ -14,9 +14,6 @@ import org.springframework.security.config.annotation.web.configuration.EnableWe
|
||||
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
|
||||
@@ -27,7 +24,9 @@ import org.springframework.security.web.authentication.logout.HttpStatusReturnin
|
||||
@EnableWebSecurity
|
||||
class SecurityConfig(
|
||||
private val environment: Environment,
|
||||
private val config: ConfigService
|
||||
private val config: ConfigService,
|
||||
private val ssoAuthenticationSuccessHandler: SsoAuthenticationSuccessHandler,
|
||||
private val sessionRegistry: SessionRegistry
|
||||
) : VaadinWebSecurity() {
|
||||
|
||||
private val ssoProviderKey: String = "oidc"
|
||||
@@ -45,13 +44,16 @@ class SecurityConfig(
|
||||
sessionManagement
|
||||
.sessionCreationPolicy(SessionCreationPolicy.IF_REQUIRED)
|
||||
.maximumSessions(3)
|
||||
.sessionRegistry(sessionRegistry())
|
||||
.sessionRegistry(sessionRegistry)
|
||||
}
|
||||
|
||||
super.configure(http)
|
||||
|
||||
if (config.getConfigValue(ConfigProperties.SsoEnabled) == true) {
|
||||
setOAuth2LoginPage(http, "/oauth2/authorization/$ssoProviderKey")
|
||||
// Use custom success handler to handle user registration
|
||||
http.oauth2Login { oauth2Login -> oauth2Login.successHandler(ssoAuthenticationSuccessHandler) }
|
||||
// Prevent unnecessary redirects
|
||||
http.logout { logout -> logout.logoutSuccessHandler((HttpStatusReturningLogoutSuccessHandler(HttpStatus.OK))) }
|
||||
} else {
|
||||
setLoginView(http, "/login")
|
||||
@@ -67,11 +69,6 @@ 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)
|
||||
@@ -92,9 +89,4 @@ class SecurityConfig(
|
||||
|
||||
return InMemoryClientRegistrationRepository(clientRegistration)
|
||||
}
|
||||
|
||||
@Bean
|
||||
fun passwordEncoder(): PasswordEncoder {
|
||||
return BCryptPasswordEncoder()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
package de.grimsi.gameyfin.meta.security
|
||||
|
||||
import org.springframework.context.annotation.Bean
|
||||
import org.springframework.context.annotation.Configuration
|
||||
import org.springframework.security.core.session.SessionRegistry
|
||||
import org.springframework.security.core.session.SessionRegistryImpl
|
||||
|
||||
@Configuration
|
||||
class SessionRegistryConfig {
|
||||
@Bean
|
||||
fun sessionRegistry(): SessionRegistry {
|
||||
return SessionRegistryImpl()
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,76 @@
|
||||
package de.grimsi.gameyfin.meta.security
|
||||
|
||||
import de.grimsi.gameyfin.config.ConfigProperties
|
||||
import de.grimsi.gameyfin.config.ConfigService
|
||||
import de.grimsi.gameyfin.config.MatchUsersBy
|
||||
import de.grimsi.gameyfin.users.RoleService
|
||||
import de.grimsi.gameyfin.users.UserService
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
import jakarta.servlet.http.HttpServletRequest
|
||||
import jakarta.servlet.http.HttpServletResponse
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUser
|
||||
import org.springframework.security.web.authentication.AuthenticationSuccessHandler
|
||||
import org.springframework.stereotype.Component
|
||||
|
||||
@Component
|
||||
class SsoAuthenticationSuccessHandler(
|
||||
private val userService: UserService,
|
||||
private val roleService: RoleService,
|
||||
private val config: ConfigService
|
||||
) : AuthenticationSuccessHandler {
|
||||
|
||||
override fun onAuthenticationSuccess(
|
||||
request: HttpServletRequest,
|
||||
response: HttpServletResponse,
|
||||
authentication: Authentication
|
||||
) {
|
||||
val oidcUser = authentication.principal as OidcUser
|
||||
|
||||
// Check if user is already registered via SSO
|
||||
var matchedUser = userService.findByOidcProviderId(oidcUser.subject)
|
||||
|
||||
// If user is not registered via SSO, check if user is already registered by username or email
|
||||
// This is meant to map existing users to SSO users
|
||||
if (matchedUser == null) {
|
||||
matchedUser = when (config.getConfigValue(ConfigProperties.SsoMatchExistingUsersBy)) {
|
||||
MatchUsersBy.USERNAME -> {
|
||||
userService.getByUsername(oidcUser.preferredUsername)
|
||||
}
|
||||
|
||||
MatchUsersBy.EMAIL -> {
|
||||
userService.getByEmail(oidcUser.email)
|
||||
}
|
||||
|
||||
else -> {
|
||||
throw IllegalStateException("Unknown 'match users by' configuration")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// User could not be found in the database
|
||||
if (matchedUser == null) {
|
||||
|
||||
// Check if new user registration is enabled
|
||||
if (config.getConfigValue(ConfigProperties.SsoAutoRegisterNewUsers) == false) {
|
||||
response.sendRedirect("/")
|
||||
return
|
||||
}
|
||||
|
||||
// Register new user
|
||||
matchedUser = User(oidcUser)
|
||||
}
|
||||
// User was found in the database, but we still want to update the user's information
|
||||
else {
|
||||
matchedUser.username = oidcUser.preferredUsername
|
||||
matchedUser.email = oidcUser.email
|
||||
}
|
||||
|
||||
val grantedAuthorities = roleService.extractGrantedAuthorities(oidcUser.authorities)
|
||||
matchedUser.roles = roleService.authoritiesToRoles(grantedAuthorities)
|
||||
userService.registerOrUpdateUser(matchedUser)
|
||||
|
||||
response.sendRedirect("/")
|
||||
return
|
||||
}
|
||||
}
|
||||
+2
-2
@@ -1,4 +1,4 @@
|
||||
package de.grimsi.gameyfin.meta
|
||||
package de.grimsi.gameyfin.meta.security
|
||||
|
||||
import de.grimsi.gameyfin.config.ConfigProperties
|
||||
import org.springframework.context.annotation.Condition
|
||||
@@ -32,7 +32,7 @@ class SsoEnabledCondition : Condition {
|
||||
} catch (_: Exception) {
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
return false
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package de.grimsi.gameyfin.meta
|
||||
package de.grimsi.gameyfin.meta.security
|
||||
|
||||
import de.grimsi.gameyfin.meta.annotations.DynamicAccessInterceptor
|
||||
import de.grimsi.gameyfin.meta.development.DelayInterceptor
|
||||
@@ -29,7 +29,7 @@ class SetupEndpoint(
|
||||
username = superAdminRegistration.username,
|
||||
password = superAdminRegistration.password,
|
||||
email = superAdminRegistration.email,
|
||||
roles = listOf(roleService.toRole(Roles.SUPERADMIN))
|
||||
roles = setOf(roleService.toRole(Roles.SUPERADMIN))
|
||||
)
|
||||
|
||||
val superAdmin = setupService.createInitialAdminUser(user)
|
||||
|
||||
@@ -25,6 +25,6 @@ class SetupService(
|
||||
* Creates the initial user with Super-Admin permissions
|
||||
*/
|
||||
fun createInitialAdminUser(superAdmin: User): User {
|
||||
return userService.registerUser(superAdmin, Roles.SUPERADMIN)
|
||||
return userService.registerOrUpdateUser(superAdmin, Roles.SUPERADMIN)
|
||||
}
|
||||
}
|
||||
@@ -4,6 +4,9 @@ import de.grimsi.gameyfin.meta.Roles
|
||||
import de.grimsi.gameyfin.users.entities.Role
|
||||
import de.grimsi.gameyfin.users.persistence.RoleRepository
|
||||
import jakarta.transaction.Transactional
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
import org.springframework.security.core.authority.SimpleGrantedAuthority
|
||||
import org.springframework.security.oauth2.core.oidc.user.OidcUserAuthority
|
||||
import org.springframework.stereotype.Service
|
||||
|
||||
@Service
|
||||
@@ -11,6 +14,12 @@ import org.springframework.stereotype.Service
|
||||
class RoleService(
|
||||
private val roleRepository: RoleRepository
|
||||
) {
|
||||
|
||||
companion object {
|
||||
const val SSO_ROLE_PREFIX = "GAMEYFIN_"
|
||||
const val INTERNAL_ROLE_PREFIX = "ROLE_"
|
||||
}
|
||||
|
||||
/**
|
||||
* @return the number of registered users with a given role
|
||||
* @return 0 if a role does not exist
|
||||
@@ -20,12 +29,50 @@ class RoleService(
|
||||
return r.users.size
|
||||
}
|
||||
|
||||
fun toRoles(roles: Collection<Roles>): List<Role> {
|
||||
return roles.mapNotNull { r -> roleRepository.findByRolename(r.roleName) }
|
||||
fun toRoles(roles: Collection<Roles>): Set<Role> {
|
||||
return roles.mapNotNull { r -> roleRepository.findByRolename(r.roleName) }.toSet()
|
||||
}
|
||||
|
||||
fun toRole(role: Roles): Role {
|
||||
return roleRepository.findByRolename(role.roleName)
|
||||
?: throw RuntimeException("Role ${role.roleName} does not exist")
|
||||
}
|
||||
|
||||
fun authoritiesToRoles(authorities: Collection<GrantedAuthority>): Set<Role> {
|
||||
return authorities.mapNotNull { a -> roleRepository.findByRolename(a.authority) }.toSet()
|
||||
}
|
||||
|
||||
/**
|
||||
* Extracts granted authorities from a collection of granted authorities.
|
||||
* Also converts SSO roles to internal roles.
|
||||
* SSO roles are assumed to be prefixed with "GAMEYFIN_" to avoid collision with other SSO apps managed by the same provider.
|
||||
* Internal roles are prefixed with "ROLE_" as per Spring Security conventions.
|
||||
* Ignore any authorities that do not start with "GAMEYFIN_".
|
||||
*
|
||||
* @return filtered and mapped collection of granted authorities
|
||||
*/
|
||||
fun extractGrantedAuthorities(authorities: Collection<GrantedAuthority>): Collection<SimpleGrantedAuthority> {
|
||||
val mappedAuthorities = authorities.asSequence()
|
||||
.filterIsInstance<OidcUserAuthority>()
|
||||
.flatMap { oidcUserAuthority ->
|
||||
val userInfo = oidcUserAuthority.userInfo
|
||||
val roles = userInfo.getClaim<List<String>>("roles")
|
||||
roles.asSequence().mapNotNull {
|
||||
if (it.startsWith(SSO_ROLE_PREFIX)) SimpleGrantedAuthority(
|
||||
it.replace(
|
||||
SSO_ROLE_PREFIX,
|
||||
INTERNAL_ROLE_PREFIX
|
||||
)
|
||||
)
|
||||
else null
|
||||
}
|
||||
}
|
||||
.toSet()
|
||||
|
||||
if (mappedAuthorities.isEmpty()) {
|
||||
mappedAuthorities.plus(SimpleGrantedAuthority(Roles.Names.USER))
|
||||
}
|
||||
|
||||
return mappedAuthorities
|
||||
}
|
||||
}
|
||||
@@ -63,6 +63,6 @@ class UserEndpoint(
|
||||
email = registration.email
|
||||
)
|
||||
|
||||
return userService.registerUser(user, roles)
|
||||
return userService.registerOrUpdateUser(user, roles)
|
||||
}
|
||||
}
|
||||
@@ -16,7 +16,6 @@ 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
|
||||
@@ -46,19 +45,23 @@ class UserService(
|
||||
)
|
||||
}
|
||||
|
||||
fun existsByUsername(username: String): Boolean = userRepository.findByUsername(username) != null
|
||||
fun existsByUsername(username: String): Boolean = userRepository.existsByUsername(username)
|
||||
|
||||
fun findByOidcProviderId(oidcProviderId: String): User? = userRepository.findByOidcProviderId(oidcProviderId)
|
||||
|
||||
fun getAllUsers(): List<UserInfoDto> {
|
||||
return userRepository.findAll().map { u -> toUserInfo(u) }
|
||||
}
|
||||
|
||||
fun getByEmail(email: String): User? {
|
||||
return userRepository.findByEmail(email)
|
||||
}
|
||||
|
||||
fun getByUsername(username: String): User? {
|
||||
return userRepository.findByUsername(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)
|
||||
}
|
||||
@@ -94,12 +97,16 @@ class UserService(
|
||||
userRepository.save(user)
|
||||
}
|
||||
|
||||
fun registerUser(user: User, role: Roles): User {
|
||||
return registerUser(user, listOf(role))
|
||||
fun registerOrUpdateUser(user: User): User {
|
||||
return userRepository.save(user)
|
||||
}
|
||||
|
||||
fun registerUser(user: User, roles: List<Roles>): User {
|
||||
user.password = passwordEncoder.encode(user.password)
|
||||
fun registerOrUpdateUser(user: User, role: Roles): User {
|
||||
return registerOrUpdateUser(user, listOf(role))
|
||||
}
|
||||
|
||||
fun registerOrUpdateUser(user: User, roles: List<Roles>): User {
|
||||
user.password?.let { user.password = passwordEncoder.encode(it) }
|
||||
user.roles = roleService.toRoles(roles)
|
||||
return userRepository.save(user)
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
package de.grimsi.gameyfin.users.entities
|
||||
|
||||
import de.grimsi.gameyfin.meta.Roles
|
||||
import jakarta.annotation.Nullable
|
||||
import jakarta.persistence.*
|
||||
import jakarta.validation.constraints.NotNull
|
||||
@@ -40,15 +39,14 @@ class User(
|
||||
joinColumns = [JoinColumn(name = "user_id", referencedColumnName = "id")],
|
||||
inverseJoinColumns = [JoinColumn(name = "role_id", referencedColumnName = "id")]
|
||||
)
|
||||
var roles: Collection<Role> = emptyList()
|
||||
var roles: Set<Role> = emptySet()
|
||||
) {
|
||||
|
||||
constructor(oidcUser: OidcUser) : this(
|
||||
username = oidcUser.preferredUsername,
|
||||
email = oidcUser.email,
|
||||
email_confirmed = true,
|
||||
enabled = true,
|
||||
oidcProviderId = oidcUser.subject
|
||||
) {
|
||||
// FIXME: Implement role mapping from OIDC provider
|
||||
this.roles = listOf(Role(Roles.ADMIN.roleName))
|
||||
}
|
||||
)
|
||||
}
|
||||
@@ -4,6 +4,8 @@ import de.grimsi.gameyfin.users.entities.User
|
||||
import org.springframework.data.jpa.repository.JpaRepository
|
||||
|
||||
interface UserRepository : JpaRepository<User, Long> {
|
||||
fun existsByUsername(userName: String): Boolean
|
||||
fun findByUsername(userName: String): User?
|
||||
fun findByEmail(email: String): User?
|
||||
fun findByOidcProviderId(oidcProviderId: String): User?
|
||||
}
|
||||
Reference in New Issue
Block a user