Moved roles from entities to enum

Added power level to roles
Implemented role assignment in frontend
This commit is contained in:
grimsi
2024-09-29 03:23:15 +02:00
parent 71aab506d5
commit a6e2965923
26 changed files with 329 additions and 168 deletions
@@ -0,0 +1,118 @@
import React, {useEffect, useState} from "react";
import {
Button,
Modal,
ModalBody,
ModalContent,
ModalFooter,
ModalHeader,
Select,
SelectedItems,
Selection,
SelectItem
} from "@nextui-org/react";
import {UserEndpoint} from "Frontend/generated/endpoints";
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
import RoleChip from "Frontend/components/general/RoleChip";
import RoleAssignmentResult from "Frontend/generated/de/grimsi/gameyfin/users/enums/RoleAssignmentResult";
interface AssignRolesModalProps {
isOpen: boolean;
onOpenChange: () => void;
user: UserInfoDto;
}
interface Role {
id: string;
}
export default function AssignRolesModal({
isOpen,
onOpenChange,
user
}: AssignRolesModalProps) {
const [availableRoles, setAvailableRoles] = useState<Role[]>([]);
const [selectedRoles, setSelectedRoles] = useState<Selection>();
const [error, setError] = useState<string>();
useEffect(() => {
setSelectedRoles(rolesToSelection(user.roles!));
UserEndpoint.getAvailableRoles().then((availableRoles) => {
setAvailableRoles(availableRoles!.map((role) => ({id: role!.toString()})));
});
}, []);
function rolesToSelection(roles: Array<string | undefined>): Selection {
return new Set(roles.map((role) => role!.toString()));
}
async function assignRoles() {
if (!selectedRoles) return;
let selectedRolesArray = Array.from(selectedRoles).map((role) => role.toString());
let result = await UserEndpoint.assignRoles(user.username, selectedRolesArray);
if (!result) return;
switch (result) {
case RoleAssignmentResult.SUCCESS:
window.location.reload();
break;
case RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH:
setError("Power level of user too high");
break;
case RoleAssignmentResult.ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH:
setError("Power level of assigned role too high");
break;
default:
setError("An error occurred");
break;
}
}
return (
<Modal isOpen={isOpen} onOpenChange={onOpenChange} backdrop="opaque" isDismissable={false}
hideCloseButton={true} size="lg">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Assign roles to {user.username}</ModalHeader>
<ModalBody className="flex flex-col gap-2">
<Select
items={availableRoles}
selectionMode="multiple"
selectedKeys={selectedRoles}
onSelectionChange={setSelectedRoles}
placeholder="Select roles"
renderValue={(items: SelectedItems<Role>) => {
return (
<div className="flex flex-grow flex-wrap gap-2">
{items.map((item) => (
<RoleChip key={item.key} role={item.textValue as string}/>
))}
</div>
);
}}
>
{(role) => (
<SelectItem key={role.id} textValue={role.id}>
<RoleChip key={role.id} role={role.id}/>
</SelectItem>
)}
</Select>
{error &&
<small className="text-danger">{error}</small>
}
</ModalBody>
<ModalFooter>
<Button variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" onPress={assignRoles} isDisabled={!selectedRoles}>
Assign roles
</Button>
</ModalFooter>
</>
)}
</ModalContent>
</Modal>
);
}
@@ -0,0 +1,10 @@
import {Chip} from "@nextui-org/react";
import {roleToColor, roleToRoleName} from "Frontend/util/utils";
export default function RoleChip({role}: { role: string }) {
return (
<Chip key={role} size="sm" radius="sm" className={`text-xs bg-${roleToColor(role)}-500`}>
{roleToRoleName(role)}
</Chip>
);
}
@@ -1,5 +1,4 @@
import {Card, Chip, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@nextui-org/react";
import {roleToColor, roleToRoleName} from "Frontend/util/utils";
import {Card, Dropdown, DropdownItem, DropdownMenu, DropdownTrigger, useDisclosure} from "@nextui-org/react";
import {DotsThreeVertical} from "@phosphor-icons/react";
import {useAuth} from "Frontend/util/auth";
import {useEffect, useState} from "react";
@@ -10,10 +9,14 @@ import ConfirmUserDeletionModal from "Frontend/components/general/ConfirmUserDel
import PasswordResetTokenModal from "Frontend/components/general/PasswortResetTokenModal";
import TokenDto from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenDto";
import UserInfoDto from "Frontend/generated/de/grimsi/gameyfin/users/dto/UserInfoDto";
import RoleChip from "Frontend/components/general/RoleChip";
import AssignRolesModal from "Frontend/components/general/AssignRolesModal";
import Role from "Frontend/generated/de/grimsi/gameyfin/core/Role";
export function UserManagementCard({user}: { user: UserInfoDto }) {
const userDeletionConfirmationModal = useDisclosure();
const passwordResetTokenModal = useDisclosure();
const roleAssignmentModal = useDisclosure();
const [userEnabled, setUserEnabled] = useState(true);
const [disabledKeys, setDisabledKeys] = useState<string[]>([]);
const [dropdownItems, setDropdownItems] = useState<any[]>([]);
@@ -40,13 +43,13 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
if (auth.state.user?.username === user.username) return false;
// User should not be able to delete the SUPERADMIN
if (user.roles?.includes("ROLE_SUPERADMIN")) return false;
if (user.roles?.includes(Role.SUPERADMIN)) return false;
// Superadmins can delete anyone excluding themselves (and other superadmins if there are any)
if (auth.state.user?.roles?.includes("ROLE_SUPERADMIN")) return true;
if (auth.state.user?.roles?.includes(Role.SUPERADMIN)) return true;
// Admins should be only allowed to delete other users, not other admins
return !user.roles?.includes("ROLE_ADMIN");
return !user.roles?.includes(Role.ADMIN);
}
async function resetPassword() {
@@ -79,6 +82,11 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
onPress: () => AvatarEndpoint.removeAvatarByName(user.username!),
label: "Remove avatar"
},
{
key: "assignRoles",
onPress: roleAssignmentModal.onOpen,
label: "Assign roles"
},
{
key: "resetPassword",
onPress: resetPassword,
@@ -108,9 +116,9 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
<div className="flex flex-col gap-1">
<p className="font-semibold">{user.username}</p>
<p className="text-sm">{user.email}</p>
{user.roles?.map((role) =>
<Chip key={role} size="sm" radius="sm"
className={`text-xs bg-${roleToColor(role!)}-500`}>{roleToRoleName(role!)}</Chip>)}
{user.roles?.map((role) => (
<RoleChip role={role as Role}/>
))}
</div>
</div>
@@ -138,6 +146,8 @@ export function UserManagementCard({user}: { user: UserInfoDto }) {
<PasswordResetTokenModal isOpen={passwordResetTokenModal.isOpen}
onOpenChange={passwordResetTokenModal.onOpenChange}
token={passwordResetToken as TokenDto}/>
<AssignRolesModal isOpen={roleAssignmentModal.isOpen} onOpenChange={roleAssignmentModal.onOpenChange}
user={user}/>
</>
)
}
@@ -3,12 +3,12 @@ package de.grimsi.gameyfin.config
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.config.dto.ConfigEntryDto
import de.grimsi.gameyfin.config.dto.ConfigValuePairDto
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
@Endpoint
@RolesAllowed(Roles.Names.ADMIN)
@RolesAllowed(Role.Names.ADMIN)
class ConfigEndpoint(
private val config: ConfigService
) {
@@ -0,0 +1,32 @@
package de.grimsi.gameyfin.core
import com.fasterxml.jackson.annotation.JsonValue
import de.grimsi.gameyfin.users.RoleService.Companion.INTERNAL_ROLE_PREFIX
enum class Role(val roleName: String, val powerLevel: Int) {
SUPERADMIN(Names.SUPERADMIN, 3),
ADMIN(Names.ADMIN, 2),
USER(Names.USER, 1);
@JsonValue
override fun toString(): String {
return this.roleName
}
companion object {
fun safeValueOf(type: String): Role? {
val enumString = type.removePrefix(INTERNAL_ROLE_PREFIX)
return java.lang.Enum.valueOf(Role::class.java, enumString)
}
}
// necessary for the ability to use the Roles class in the @RolesAllowed annotation
class Names {
companion object {
const val SUPERADMIN = "${INTERNAL_ROLE_PREFIX}SUPERADMIN"
const val ADMIN = "${INTERNAL_ROLE_PREFIX}ADMIN"
const val USER = "${INTERNAL_ROLE_PREFIX}USER"
}
}
}
@@ -1,18 +0,0 @@
package de.grimsi.gameyfin.core
import de.grimsi.gameyfin.users.RoleService.Companion.INTERNAL_ROLE_PREFIX
enum class Roles(val roleName: String) {
SUPERADMIN(Names.SUPERADMIN),
ADMIN(Names.ADMIN),
USER(Names.USER);
// necessary for the ability to use the Roles class in the @RolesAllowed annotation
class Names {
companion object {
const val SUPERADMIN = "${INTERNAL_ROLE_PREFIX}SUPERADMIN"
const val ADMIN = "${INTERNAL_ROLE_PREFIX}ADMIN"
const val USER = "${INTERNAL_ROLE_PREFIX}USER"
}
}
}
@@ -2,9 +2,7 @@ package de.grimsi.gameyfin.core
import de.grimsi.gameyfin.setup.SetupService
import de.grimsi.gameyfin.users.UserService
import de.grimsi.gameyfin.users.entities.Role
import de.grimsi.gameyfin.users.entities.User
import de.grimsi.gameyfin.users.persistence.RoleRepository
import io.github.oshai.kotlinlogging.KotlinLogging
import jakarta.transaction.Transactional
import org.springframework.boot.context.event.ApplicationReadyEvent
@@ -17,7 +15,6 @@ import java.net.InetAddress
@Service
@Transactional
class SetupDataLoader(
private val roleRepository: RoleRepository,
private val userService: UserService,
private val setupService: SetupService,
private val env: Environment
@@ -29,39 +26,16 @@ class SetupDataLoader(
if (setupService.isSetupCompleted()) return
log.info { "Looks like this is the first time you're starting Gameyfin." }
log.info { "We will now set up some data..." }
setupRoles()
if ("dev" in env.activeProfiles) {
log.info { "We will now set up some data for local development..." }
setupUsers()
log.info { "Setup completed..." }
}
log.info { "Setup completed..." }
log.info { "Visit http://${InetAddress.getLocalHost().hostName}:${env.getProperty("server.port")}/setup to complete the setup" }
}
val protocol = if (env.getProperty("server.ssl.key-store") != null) "https" else "http"
fun setupRoles() {
log.info { "Setting up roles..." }
createRoleIfNotFound(Roles.SUPERADMIN.roleName)
createRoleIfNotFound(Roles.ADMIN.roleName)
createRoleIfNotFound(Roles.USER.roleName)
log.info { "Role setup completed." }
}
fun createRoleIfNotFound(name: String): Role {
log.info { "Creating role $name" }
var role: Role? = roleRepository.findByRolename(name)
if (role == null) {
role = Role(name)
roleRepository.save(role)
}
return role
log.info { "Visit $protocol://${InetAddress.getLocalHost().hostName}:${env.getProperty("server.port")}/setup to complete the setup" }
}
fun setupUsers() {
@@ -72,27 +46,29 @@ class SetupDataLoader(
password = "admin",
email = "admin@gameyfin.org",
emailConfirmed = true,
enabled = true
enabled = true,
roles = setOf(Role.SUPERADMIN)
)
registerUserIfNotFound(superadmin, Roles.SUPERADMIN)
registerUserIfNotFound(superadmin)
val user = User(
username = "user",
password = "user",
email = "user@gameyfin.org",
emailConfirmed = true,
enabled = true
enabled = true,
roles = setOf(Role.USER)
)
registerUserIfNotFound(user, Roles.USER)
registerUserIfNotFound(user)
log.info { "User setup completed." }
}
fun registerUserIfNotFound(user: User, role: Roles) {
fun registerUserIfNotFound(user: User) {
if (userService.existsByUsername(user.username)) return
userService.registerOrUpdateUser(user, role)
userService.registerOrUpdateUser(user)
}
}
@@ -1,7 +1,7 @@
package de.grimsi.gameyfin.libraries
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.libraries.entities.Library
import jakarta.annotation.security.RolesAllowed
@@ -9,7 +9,7 @@ import jakarta.annotation.security.RolesAllowed
class LibraryEndpoint(
private val libraryService: LibraryService
) {
@RolesAllowed(Roles.Names.ADMIN)
@RolesAllowed(Role.Names.ADMIN)
fun getAllLibraries(): Collection<Library> {
return libraryService.getAllLibraries()
}
@@ -2,12 +2,12 @@ package de.grimsi.gameyfin.logs
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.RolesAllowed
import reactor.core.publisher.Flux
@Endpoint
@RolesAllowed(Roles.Names.ADMIN)
@RolesAllowed(Role.Names.ADMIN)
class LogEndpoint(
private val logService: LogService
) {
@@ -2,11 +2,11 @@ package de.grimsi.gameyfin.messages
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.RolesAllowed
@Endpoint
@RolesAllowed(Roles.Names.ADMIN)
@RolesAllowed(Role.Names.ADMIN)
class MessageEndpoint(
private val messageService: MessageService
) {
@@ -1,10 +1,10 @@
package de.grimsi.gameyfin.messages.templates
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.RolesAllowed
@RolesAllowed(Roles.Names.ADMIN)
@RolesAllowed(Role.Names.ADMIN)
@Endpoint
class MessageTemplateEndpoint(
private val messageTemplateService: MessageTemplateService
@@ -1,6 +1,6 @@
package de.grimsi.gameyfin.setup
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.users.RoleService
import de.grimsi.gameyfin.users.UserService
import de.grimsi.gameyfin.users.dto.UserInfoDto
@@ -20,7 +20,7 @@ class SetupService(
* 1. At least one user with "Super Admin" role
*/
fun isSetupCompleted(): Boolean {
return roleService.getUserCountForRole(Roles.SUPERADMIN) > 0
return roleService.getUserCountForRole(Role.SUPERADMIN) > 0
}
/**
@@ -31,11 +31,11 @@ class SetupService(
username = registration.username,
password = registration.password,
email = registration.email,
roles = setOf(roleService.toRole(Roles.SUPERADMIN)),
enabled = true
enabled = true,
roles = setOf(Role.SUPERADMIN)
)
val user = userService.registerOrUpdateUser(superAdmin, Roles.SUPERADMIN)
val user = userService.registerOrUpdateUser(superAdmin)
return userService.toUserInfo(user)
}
}
@@ -1,7 +1,7 @@
package de.grimsi.gameyfin.system
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Role
import jakarta.annotation.security.RolesAllowed
@Endpoint
@@ -9,7 +9,7 @@ class SystemEndpoint(
private val systemService: SystemService
) {
@RolesAllowed(Roles.Names.ADMIN)
@RolesAllowed(Role.Names.ADMIN)
fun restart() {
systemService.restart()
}
@@ -1,9 +1,10 @@
package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.users.entities.Role
import de.grimsi.gameyfin.users.persistence.RoleRepository
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.users.entities.User
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.oauth2.core.oidc.user.OidcUserAuthority
@@ -12,7 +13,7 @@ import org.springframework.stereotype.Service
@Service
@Transactional
class RoleService(
private val roleRepository: RoleRepository
private val userRepository: UserRepository
) {
companion object {
@@ -20,26 +21,37 @@ class RoleService(
const val INTERNAL_ROLE_PREFIX = "ROLE_"
}
fun getAllRoles(): List<Role> {
return Role.entries
}
/**
* @return the number of registered users with a given role
* @return 0 if a role does not exist
*/
fun getUserCountForRole(role: Roles): Int {
val r = roleRepository.findByRolename(role.roleName) ?: return 0
return r.users.size
fun getUserCountForRole(role: Role): Int {
return userRepository.countUserByRolesContains(role)
}
fun toRoles(roles: Collection<Roles>): Set<Role> {
return roles.mapNotNull { r -> roleRepository.findByRolename(r.roleName) }.toSet()
fun getHighestRole(roles: Collection<Role>): Role {
return roles.maxByOrNull { it.powerLevel } ?: Role.USER
}
fun toRole(role: Roles): Role {
return roleRepository.findByRolename(role.roleName)
?: throw RuntimeException("Role ${role.roleName} does not exist")
fun getHighestRoleFromAuthorities(authorities: Collection<GrantedAuthority>): Role {
return getHighestRole(authoritiesToRoles(authorities))
}
fun getRolesBelowUser(user: User): List<Role> {
val highestUserRole = getHighestRole(user.roles)
return Role.entries.filter { it.powerLevel < highestUserRole.powerLevel }
}
fun getRolesBelowAuth(auth: Authentication): List<Role> {
val highestUserRole = getHighestRole(auth.authorities.mapNotNull { Role.safeValueOf(it.authority) })
return Role.entries.filter { it.powerLevel < highestUserRole.powerLevel }
}
fun authoritiesToRoles(authorities: Collection<GrantedAuthority>): Set<Role> {
return authorities.mapNotNull { a -> roleRepository.findByRolename(a.authority) }.toSet()
return authorities.mapNotNull { Role.safeValueOf(it.authority) }.toMutableSet()
}
/**
@@ -59,18 +71,16 @@ class RoleService(
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
)
it.replace(SSO_ROLE_PREFIX, INTERNAL_ROLE_PREFIX)
)
else null
}
}
.toSet()
// Add USER role if no roles are present
if (mappedAuthorities.isEmpty()) {
mappedAuthorities.plus(SimpleGrantedAuthority(Roles.Names.USER))
mappedAuthorities.plus(SimpleGrantedAuthority(Role.Names.USER))
}
return mappedAuthorities
@@ -1,9 +1,10 @@
package de.grimsi.gameyfin.users
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.users.dto.UserInfoDto
import de.grimsi.gameyfin.users.dto.UserUpdateDto
import de.grimsi.gameyfin.users.enums.RoleAssignmentResult
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
import org.springframework.security.core.Authentication
@@ -12,7 +13,8 @@ import org.springframework.security.core.context.SecurityContextHolder
@Endpoint
class UserEndpoint(
private val userService: UserService
private val userService: UserService,
private val roleService: RoleService
) {
@PermitAll
fun existsByMail(email: String): Boolean {
@@ -25,18 +27,18 @@ class UserEndpoint(
return userService.getUserInfo(auth)
}
@RolesAllowed(Roles.Names.ADMIN)
fun getAllUsers(): List<UserInfoDto> {
return userService.getAllUsers()
}
@PermitAll
fun updateUser(updates: UserUpdateDto) {
val auth: Authentication = SecurityContextHolder.getContext().authentication
userService.updateUser(auth.name, updates)
}
@RolesAllowed(Roles.Names.ADMIN)
@RolesAllowed(Role.Names.ADMIN)
fun getAllUsers(): List<UserInfoDto> {
return userService.getAllUsers()
}
@RolesAllowed(Role.Names.ADMIN)
fun updateUserByName(username: String, updates: UserUpdateDto) {
userService.updateUser(username, updates)
}
@@ -47,8 +49,24 @@ class UserEndpoint(
userService.deleteUser(auth.name)
}
@RolesAllowed(Roles.Names.ADMIN)
@RolesAllowed(Role.Names.ADMIN)
fun deleteUserByName(username: String) {
userService.deleteUser(username)
}
@RolesAllowed(Role.Names.ADMIN)
fun getAvailableRoles(): List<String> {
return roleService.getAllRoles().map { it.roleName }
}
@RolesAllowed(Role.Names.ADMIN)
fun getRolesBelow(): List<String> {
val auth: Authentication = SecurityContextHolder.getContext().authentication
return roleService.getRolesBelowAuth(auth).map { it.roleName }
}
@RolesAllowed(Role.Names.ADMIN)
fun assignRoles(username: String, roles: List<String>): RoleAssignmentResult {
return userService.assignRoles(username, roles)
}
}
@@ -2,7 +2,7 @@ package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.config.ConfigProperties
import de.grimsi.gameyfin.config.ConfigService
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.core.Utils
import de.grimsi.gameyfin.core.events.EmailNeedsConfirmationEvent
import de.grimsi.gameyfin.core.events.RegistrationAttemptWithExistingEmailEvent
@@ -13,8 +13,8 @@ import de.grimsi.gameyfin.users.dto.UserRegistrationDto
import de.grimsi.gameyfin.users.dto.UserUpdateDto
import de.grimsi.gameyfin.users.emailconfirmation.EmailConfirmationService
import de.grimsi.gameyfin.users.entities.Avatar
import de.grimsi.gameyfin.users.entities.Role
import de.grimsi.gameyfin.users.entities.User
import de.grimsi.gameyfin.users.enums.RoleAssignmentResult
import de.grimsi.gameyfin.users.persistence.AvatarContentStore
import de.grimsi.gameyfin.users.persistence.UserRepository
import io.github.oshai.kotlinlogging.KotlinLogging
@@ -23,6 +23,7 @@ import org.springframework.context.ApplicationEventPublisher
import org.springframework.security.core.Authentication
import org.springframework.security.core.GrantedAuthority
import org.springframework.security.core.authority.SimpleGrantedAuthority
import org.springframework.security.core.context.SecurityContextHolder
import org.springframework.security.core.userdetails.UserDetails
import org.springframework.security.core.userdetails.UserDetailsService
import org.springframework.security.core.userdetails.UsernameNotFoundException
@@ -88,7 +89,9 @@ class UserService(
if (principal is OidcUser) {
val oidcUser = User(principal)
val userInfoDto = toUserInfo(oidcUser)
userInfoDto.roles = roleService.extractGrantedAuthorities(principal.authorities).map { it.authority }
userInfoDto.roles = roleService.extractGrantedAuthorities(principal.authorities)
.mapNotNull { Role.safeValueOf(it.authority) }
.toSet()
return userInfoDto
}
@@ -128,16 +131,7 @@ class UserService(
}
fun registerOrUpdateUser(user: User): User {
return userRepository.save(user)
}
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)
user.password = passwordEncoder.encode(user.password)
return userRepository.save(user)
}
@@ -162,7 +156,7 @@ class UserService(
password = passwordEncoder.encode(registration.password),
email = registration.email,
enabled = !adminNeedsToApprove,
roles = roleService.toRoles(listOf(Roles.USER))
roles = setOf(Role.USER)
)
user = userRepository.save(user)
@@ -186,7 +180,7 @@ class UserService(
email = email,
emailConfirmed = true,
enabled = true,
roles = roleService.toRoles(listOf(Roles.USER))
roles = setOf(Role.USER)
)
return userRepository.save(user)
@@ -224,6 +218,31 @@ class UserService(
eventPublisher.publishEvent(UserRegistrationEvent(this, user, Utils.getBaseUrl()))
}
fun assignRoles(username: String, roleNames: List<String>): RoleAssignmentResult {
val currentUser = SecurityContextHolder.getContext().authentication
val targetUser = userByUsername(username)
val currentUserLevel = roleService.getHighestRoleFromAuthorities(currentUser.authorities).powerLevel
val targetUserLevel = roleService.getHighestRole(targetUser.roles).powerLevel
if (currentUserLevel <= targetUserLevel) {
log.error { "User ${currentUser.name} tried to assign roles to user with higher or equal power level to their own" }
return RoleAssignmentResult.TARGET_POWER_LEVEL_TOO_HIGH
}
val newAssignedRoles = roleNames.mapNotNull { r -> Role.safeValueOf(r) }
val newAssignedRolesLevel = roleService.getHighestRole(newAssignedRoles).powerLevel
if (currentUserLevel <= newAssignedRolesLevel) {
log.error { "User ${currentUser.name} tried to assign roles with higher or equal power level than their own" }
return RoleAssignmentResult.ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH
}
targetUser.roles = newAssignedRoles.toMutableSet()
userRepository.save(targetUser)
return RoleAssignmentResult.SUCCESS
}
fun deleteUser(username: String) {
val user = userByUsername(username)
userRepository.delete(user)
@@ -237,12 +256,12 @@ class UserService(
isEnabled = user.enabled,
hasAvatar = user.avatar != null,
managedBySso = user.oidcProviderId != null,
roles = user.roles.map { r -> r.rolename }
roles = user.roles
)
}
private fun toAuthorities(roles: Collection<Role>): List<GrantedAuthority> {
return roles.map { r -> SimpleGrantedAuthority(r.rolename) }
return roles.map { r -> SimpleGrantedAuthority(r.roleName) }
}
private fun userByUsername(username: String): User {
@@ -1,6 +1,6 @@
package de.grimsi.gameyfin.users.avatar
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.users.UserService
import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
@@ -35,7 +35,7 @@ class AvatarController(
userService.deleteAvatar(auth.name)
}
@RolesAllowed(Roles.Names.ADMIN)
@RolesAllowed(Role.Names.ADMIN)
@PostMapping("/avatar/deleteByName")
fun deleteAvatarByName(@RequestParam("name") name: String) {
userService.deleteAvatar(name)
@@ -1,5 +1,7 @@
package de.grimsi.gameyfin.users.dto
import de.grimsi.gameyfin.core.Role
data class UserInfoDto(
val username: String,
val managedBySso: Boolean,
@@ -7,5 +9,5 @@ data class UserInfoDto(
val emailConfirmed: Boolean,
val isEnabled: Boolean,
val hasAvatar: Boolean,
var roles: List<String>
var roles: Set<Role>
)
@@ -1,19 +0,0 @@
package de.grimsi.gameyfin.users.entities
import jakarta.persistence.*
import jakarta.validation.constraints.NotNull
@Entity
class Role(
@NotNull
@Column(unique = true)
var rolename: String,
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null,
@ManyToMany(mappedBy = "roles", fetch = FetchType.EAGER)
var users: Collection<User> = emptyList()
)
@@ -1,5 +1,6 @@
package de.grimsi.gameyfin.users.entities
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.core.security.EncryptionConverter
import jakarta.annotation.Nullable
import jakarta.persistence.*
@@ -35,12 +36,8 @@ class User(
@Nullable
var avatar: Avatar? = null,
@ManyToMany(fetch = FetchType.EAGER)
@JoinTable(
name = "users_roles",
joinColumns = [JoinColumn(name = "user_id", referencedColumnName = "id")],
inverseJoinColumns = [JoinColumn(name = "role_id", referencedColumnName = "id")]
)
@ElementCollection(targetClass = Role::class, fetch = FetchType.EAGER)
@Enumerated(EnumType.STRING)
var roles: Set<Role> = emptySet()
) {
@@ -0,0 +1,7 @@
package de.grimsi.gameyfin.users.enums
enum class RoleAssignmentResult {
SUCCESS,
TARGET_POWER_LEVEL_TOO_HIGH,
ASSIGNED_ROLE_POWER_LEVEL_TOO_HIGH
}
@@ -2,7 +2,7 @@ package de.grimsi.gameyfin.users.passwordreset
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.shared.token.TokenDto
import de.grimsi.gameyfin.shared.token.TokenValidationResult
import de.grimsi.gameyfin.users.UserService
@@ -21,7 +21,7 @@ class PasswordResetEndpoint(
// No return value to prevent enumeration attacks
}
@RolesAllowed(Roles.Names.ADMIN)
@RolesAllowed(Role.Names.ADMIN)
fun createPasswordResetTokenForUser(username: String): TokenDto {
return passwordResetService.generate(username)
}
@@ -1,8 +0,0 @@
package de.grimsi.gameyfin.users.persistence
import de.grimsi.gameyfin.users.entities.Role
import org.springframework.data.jpa.repository.JpaRepository
interface RoleRepository : JpaRepository<Role, Long> {
fun findByRolename(roleName: String): Role?
}
@@ -1,5 +1,6 @@
package de.grimsi.gameyfin.users.persistence
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.users.entities.User
import org.springframework.data.jpa.repository.JpaRepository
@@ -9,4 +10,5 @@ interface UserRepository : JpaRepository<User, Long> {
fun findByUsername(userName: String): User?
fun findByEmail(email: String): User?
fun findByOidcProviderId(oidcProviderId: String): User?
fun countUserByRolesContains(role: Role): Int
}
@@ -2,7 +2,7 @@ package de.grimsi.gameyfin.users.registration
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.shared.token.TokenDto
import de.grimsi.gameyfin.shared.token.TokenValidationResult
import de.grimsi.gameyfin.users.UserService
@@ -37,12 +37,12 @@ class RegistrationEndpoint(
return invitationService.getAssociatedEmail(token)
}
@RolesAllowed(Roles.Names.ADMIN)
@RolesAllowed(Role.Names.ADMIN)
fun confirmRegistration(username: String) {
userService.confirmRegistration(username)
}
@RolesAllowed(Roles.Names.ADMIN)
@RolesAllowed(Role.Names.ADMIN)
fun createInvitation(email: String): TokenDto {
return invitationService.createInvitation(email)
}
@@ -3,13 +3,18 @@
package de.grimsi.gameyfin.users.util
import de.grimsi.gameyfin.core.Roles
import de.grimsi.gameyfin.core.Role
import de.grimsi.gameyfin.users.entities.User
import org.springframework.security.core.userdetails.UserDetails
fun UserDetails.hasRole(role: Roles): Boolean {
fun User.hasRole(role: Role): Boolean {
return role.roleName in this.roles.map { r -> r.roleName }
}
fun UserDetails.hasRole(role: Role): Boolean {
return role.roleName in this.authorities.map { a -> a.authority }
}
fun UserDetails.isAdmin(): Boolean {
return hasRole(Roles.SUPERADMIN) || hasRole(Roles.ADMIN)
return hasRole(Role.SUPERADMIN) || hasRole(Role.ADMIN)
}