Implement role mapping from SSO

This commit is contained in:
grimsi
2024-09-17 16:15:01 +02:00
parent 0cdeffde51
commit 9540e1e5fe
17 changed files with 216 additions and 47 deletions
@@ -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()
}
}
@@ -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
}
}
@@ -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,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?
}