+
+
ROLE_ADMIN
+ |ROLE_ADMIN > ROLE_USER""".trimMargin()
+ )
+ }
+}
\ No newline at end of file
diff --git a/src/main/kotlin/de/grimsi/gameyfin/core/security/SecurityConfig.kt b/src/main/kotlin/de/grimsi/gameyfin/core/security/SecurityConfig.kt
index bcbae84..cb41a94 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/core/security/SecurityConfig.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/core/security/SecurityConfig.kt
@@ -36,6 +36,7 @@ class SecurityConfig(
// Configure your static resources with public access before calling super.configure(HttpSecurity) as it adds final anyRequest matcher
http.authorizeHttpRequests { auth: AuthorizeHttpRequestsConfigurer.AuthorizationManagerRequestMatcherRegistry ->
auth.requestMatchers("/setup").permitAll()
+ .requestMatchers("/reset-password").permitAll()
.requestMatchers("/public/**").permitAll()
.requestMatchers("/images/**").permitAll()
}
diff --git a/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt
index 8103db0..407a20a 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/libraries/LibraryEndpoint.kt
@@ -1,15 +1,15 @@
package de.grimsi.gameyfin.libraries
import com.vaadin.hilla.Endpoint
-import de.grimsi.gameyfin.libraries.entities.Library
import de.grimsi.gameyfin.core.Roles
+import de.grimsi.gameyfin.libraries.entities.Library
import jakarta.annotation.security.RolesAllowed
@Endpoint
class LibraryEndpoint(
private val libraryService: LibraryService
) {
- @RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
+ @RolesAllowed(Roles.Names.ADMIN)
fun getAllLibraries(): Collection {
return libraryService.getAllLibraries()
}
diff --git a/src/main/kotlin/de/grimsi/gameyfin/logs/LogEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/logs/LogEndpoint.kt
index 54a652c..3243a28 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/logs/LogEndpoint.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/logs/LogEndpoint.kt
@@ -7,7 +7,7 @@ import jakarta.annotation.security.RolesAllowed
import reactor.core.publisher.Flux
@Endpoint
-@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
+@RolesAllowed(Roles.Names.ADMIN)
class LogEndpoint(
private val logService: LogService
) {
diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationEndpoint.kt
index 7ac835b..879c25d 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationEndpoint.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationEndpoint.kt
@@ -1,15 +1,21 @@
package de.grimsi.gameyfin.notifications
+import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import jakarta.annotation.security.RolesAllowed
@Endpoint
-@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
+@RolesAllowed(Roles.Names.ADMIN)
class NotificationEndpoint(
private val notificationService: NotificationService
) {
+ @AnonymousAllowed
+ fun isEnabled(): Boolean {
+ return notificationService.enabled
+ }
+
fun verifyCredentials(provider: String, credentials: Map): Boolean {
return notificationService.testCredentials(provider, credentials)
}
diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationService.kt b/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationService.kt
index a4946d6..501e136 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationService.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/notifications/NotificationService.kt
@@ -42,6 +42,12 @@ class NotificationService(
@Async
@EventListener(PasswordResetRequestEvent::class)
fun onPasswordResetRequest(event: PasswordResetRequestEvent) {
+
+ if (!enabled) {
+ log.error { "No notification provider available, can't send password reset message" }
+ return
+ }
+
log.info { "Sending password reset request notification" }
val token = event.token
diff --git a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplateEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplateEndpoint.kt
index 0f113aa..9c643a9 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplateEndpoint.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/notifications/templates/MessageTemplateEndpoint.kt
@@ -4,7 +4,7 @@ import com.vaadin.hilla.Endpoint
import de.grimsi.gameyfin.core.Roles
import jakarta.annotation.security.RolesAllowed
-@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
+@RolesAllowed(Roles.Names.ADMIN)
@Endpoint
class MessageTemplateEndpoint(
private val messageTemplateService: MessageTemplateService
diff --git a/src/main/kotlin/de/grimsi/gameyfin/system/SystemEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/system/SystemEndpoint.kt
index b3d53c1..766679c 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/system/SystemEndpoint.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/system/SystemEndpoint.kt
@@ -9,7 +9,7 @@ class SystemEndpoint(
private val systemService: SystemService
) {
- @RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
+ @RolesAllowed(Roles.Names.ADMIN)
fun restart() {
systemService.restart()
}
diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt
index f1b3a5a..f86d2be 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetEndpoint.kt
@@ -2,6 +2,9 @@ package de.grimsi.gameyfin.users
import com.vaadin.flow.server.auth.AnonymousAllowed
import com.vaadin.hilla.Endpoint
+import de.grimsi.gameyfin.core.Roles
+import de.grimsi.gameyfin.users.dto.PasswordResetResult
+import jakarta.annotation.security.RolesAllowed
@Endpoint
@AnonymousAllowed
@@ -11,9 +14,16 @@ class PasswordResetEndpoint(
fun requestPasswordReset(email: String) {
passwordResetService.requestPasswordReset(email)
+
+ // No return value to prevent enumeration attacks
}
- fun resetPassword(token: String, newPassword: String) {
+ @RolesAllowed(Roles.Names.ADMIN)
+ fun createPasswordResetTokenForUser(username: String): String {
+ return passwordResetService.createPasswordResetToken(username)
+ }
+ fun resetPassword(token: String, newPassword: String): PasswordResetResult {
+ return passwordResetService.resetPassword(token, newPassword)
}
}
\ No newline at end of file
diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt
index 9ae6e8e..01d5e6a 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/users/PasswordResetService.kt
@@ -2,6 +2,8 @@ package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.core.Utils
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
+import de.grimsi.gameyfin.notifications.NotificationService
+import de.grimsi.gameyfin.users.dto.PasswordResetResult
import de.grimsi.gameyfin.users.entities.PasswordResetToken
import de.grimsi.gameyfin.users.entities.User
import de.grimsi.gameyfin.users.persistence.PasswordResetTokenRepository
@@ -18,6 +20,7 @@ import kotlin.time.toJavaDuration
class PasswordResetService(
private val userService: UserService,
private val sessionService: SessionService,
+ private val notificationService: NotificationService,
private val eventPublisher: ApplicationEventPublisher,
private val passwordResetTokenRepository: PasswordResetTokenRepository
) {
@@ -36,23 +39,46 @@ class PasswordResetService(
private val baseUrl: String
get() = Utils.getBaseUrl()
+ /**
+ * Users can request a password reset when the following conditions are met:
+ * - The user has confirmed their email address
+ * - The user is not managed externally
+ */
fun requestPasswordReset(email: String) {
- log.info { "Initiating password reset request for '${Utils.maskEmail(email)}'" }
+ val maskedEmail = Utils.maskEmail(email)
+
+ log.info { "Initiating password reset request for '${maskedEmail}'" }
val user = userService.getByEmail(email)
- // A user can only reset its password if its email is confirmed, and it's not an SSO user
- if (user != null && user.emailConfirmed && user.oidcProviderId == null) {
- val token = createPasswordResetToken(user)
- eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, baseUrl))
+ if (user == null) {
+ log.error { "No user with email '${maskedEmail}' found" }
+ return
}
+ if (!user.emailConfirmed) {
+ log.error { "User with email '${maskedEmail}' has not confirmed their email address" }
+ return
+ }
+
+ if (user.oidcProviderId != null) {
+ log.error { "User with email '${maskedEmail}' is managed externally" }
+ return
+ }
+
+ val token = createPasswordResetToken(user)
+ eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, baseUrl))
+
// Simulate a delay to prevent timing attacks
Thread.sleep(secureRandom.nextLong(1024))
}
fun createPasswordResetToken(user: User): PasswordResetToken {
+ if (user.oidcProviderId != null) {
+ throw IllegalStateException("Cannot create password reset token for user '${user.username}' because user is managed externally")
+ }
+
val token = PasswordResetToken(
user = user,
token = UUID.randomUUID().toString()
@@ -65,13 +91,35 @@ class PasswordResetService(
return passwordResetTokenRepository.save(token)
}
- fun resetPassword(token: String, newPassword: String) {
+ /**
+ * Admins should be able to create password reset tokens for users when the following conditions are met:
+ * - E-Mail notifications are not enabled
+ * - The user has no confirmed email address
+ * - The user is not managed externally
+ */
+ fun createPasswordResetToken(username: String): String {
+ if (notificationService.enabled) {
+ throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled")
+ }
+
+ val user = userService.getByUsername(username)
+ ?: throw IllegalArgumentException("Cannot create password reset token for user '$username' because user does not exist")
+
+ if (user.emailConfirmed == true) {
+ throw IllegalStateException("Cannot create password reset token for user '$username' because self-service is enabled")
+ }
+
+ return createPasswordResetToken(user).token
+ }
+
+
+ fun resetPassword(token: String, newPassword: String): PasswordResetResult {
val passwordResetToken =
passwordResetTokenRepository.findByToken(token)
- ?: throw IllegalArgumentException("Token not found")
+ ?: return PasswordResetResult.INVALID_TOKEN
if (passwordResetToken.isExpired) {
- throw IllegalStateException("Token is expired")
+ return PasswordResetResult.EXPIRED_TOKEN
}
val user = passwordResetToken.user
@@ -79,5 +127,6 @@ class PasswordResetService(
userService.updatePassword(user, newPassword)
passwordResetTokenRepository.delete(passwordResetToken)
sessionService.logoutAllSessions(user)
+ return PasswordResetResult.SUCCESS
}
}
\ No newline at end of file
diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt
index 35ef36e..79edfc7 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/users/UserEndpoint.kt
@@ -23,7 +23,7 @@ class UserEndpoint(
return userService.getUserInfo(auth)
}
- @RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
+ @RolesAllowed(Roles.Names.ADMIN)
fun getAllUsers(): List {
return userService.getAllUsers()
}
@@ -40,7 +40,7 @@ class UserEndpoint(
userService.updateUser(auth.name, updates)
}
- @RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
+ @RolesAllowed(Roles.Names.ADMIN)
fun updateUserByName(username: String, updates: UserUpdateDto) {
userService.updateUser(username, updates)
}
@@ -51,7 +51,7 @@ class UserEndpoint(
userService.deleteUser(auth.name)
}
- @RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
+ @RolesAllowed(Roles.Names.ADMIN)
fun deleteUserByName(username: String) {
userService.deleteUser(username)
}
diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/avatar/AvatarController.kt b/src/main/kotlin/de/grimsi/gameyfin/users/avatar/AvatarController.kt
index 052d572..fa58d24 100644
--- a/src/main/kotlin/de/grimsi/gameyfin/users/avatar/AvatarController.kt
+++ b/src/main/kotlin/de/grimsi/gameyfin/users/avatar/AvatarController.kt
@@ -35,7 +35,7 @@ class AvatarController(
userService.deleteAvatar(auth.name)
}
- @RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
+ @RolesAllowed(Roles.Names.ADMIN)
@PostMapping("/avatar/deleteByName")
fun deleteAvatarByName(@RequestParam("name") name: String) {
userService.deleteAvatar(name)
diff --git a/src/main/kotlin/de/grimsi/gameyfin/users/dto/PasswordResetResult.kt b/src/main/kotlin/de/grimsi/gameyfin/users/dto/PasswordResetResult.kt
new file mode 100644
index 0000000..c13096a
--- /dev/null
+++ b/src/main/kotlin/de/grimsi/gameyfin/users/dto/PasswordResetResult.kt
@@ -0,0 +1,5 @@
+package de.grimsi.gameyfin.users.dto
+
+enum class PasswordResetResult() {
+ SUCCESS, INVALID_TOKEN, EXPIRED_TOKEN
+}
\ No newline at end of file