From 8cf6236b1daaa6b3154a22b29d1d824900cca789 Mon Sep 17 00:00:00 2001 From: grimsi <9295182+grimsi@users.noreply.github.com> Date: Mon, 23 Sep 2024 18:40:16 +0200 Subject: [PATCH] Implemented hierarchical roles Finished password reset flow in frontend --- src/main/frontend/routes.tsx | 4 + src/main/frontend/views/LoginView.tsx | 48 ++++++---- src/main/frontend/views/PasswordResetView.tsx | 92 +++++++++++++++++++ src/main/frontend/views/SetupView.tsx | 4 +- .../grimsi/gameyfin/config/ConfigEndpoint.kt | 2 +- .../security/AuthenticationProviderConfig.kt | 25 +++++ .../core/security/RoleHierarchyConfig.kt | 17 ++++ .../gameyfin/core/security/SecurityConfig.kt | 1 + .../gameyfin/libraries/LibraryEndpoint.kt | 4 +- .../de/grimsi/gameyfin/logs/LogEndpoint.kt | 2 +- .../notifications/NotificationEndpoint.kt | 8 +- .../notifications/NotificationService.kt | 6 ++ .../templates/MessageTemplateEndpoint.kt | 2 +- .../grimsi/gameyfin/system/SystemEndpoint.kt | 2 +- .../gameyfin/users/PasswordResetEndpoint.kt | 12 ++- .../gameyfin/users/PasswordResetService.kt | 65 +++++++++++-- .../de/grimsi/gameyfin/users/UserEndpoint.kt | 6 +- .../gameyfin/users/avatar/AvatarController.kt | 2 +- .../gameyfin/users/dto/PasswordResetResult.kt | 5 + 19 files changed, 269 insertions(+), 38 deletions(-) create mode 100644 src/main/frontend/views/PasswordResetView.tsx create mode 100644 src/main/kotlin/de/grimsi/gameyfin/core/security/AuthenticationProviderConfig.kt create mode 100644 src/main/kotlin/de/grimsi/gameyfin/core/security/RoleHierarchyConfig.kt create mode 100644 src/main/kotlin/de/grimsi/gameyfin/users/dto/PasswordResetResult.kt diff --git a/src/main/frontend/routes.tsx b/src/main/frontend/routes.tsx index e198f76..e989334 100644 --- a/src/main/frontend/routes.tsx +++ b/src/main/frontend/routes.tsx @@ -14,6 +14,7 @@ import {AdministrationView} from "Frontend/views/AdministrationView"; import {ProfileView} from "Frontend/views/ProfileView"; import {NotificationManagement} from "Frontend/components/administration/NotificationManagement"; import {LogManagement} from "Frontend/components/administration/LogManagement"; +import PasswordResetView from "Frontend/views/PasswordResetView"; export const routes = protectRoutes([ { @@ -53,6 +54,9 @@ export const routes = protectRoutes([ }, { path: '/setup', element: , handle: {requiresLogin: false} + }, + { + path: '/reset-password', element: , handle: {requiresLogin: false} } ], } diff --git a/src/main/frontend/views/LoginView.tsx b/src/main/frontend/views/LoginView.tsx index 7dc184a..0b31a31 100644 --- a/src/main/frontend/views/LoginView.tsx +++ b/src/main/frontend/views/LoginView.tsx @@ -1,6 +1,6 @@ import {useAuth} from "Frontend/util/auth"; -import {useLayoutEffect, useState} from "react"; -import {XCircle} from "@phosphor-icons/react"; +import {useEffect, useState} from "react"; +import {WarningCircle, XCircle} from "@phosphor-icons/react"; import { Button, Card, @@ -17,7 +17,7 @@ import { } from "@nextui-org/react"; import {Alert, AlertDescription, AlertTitle} from "Frontend/@/components/ui/alert"; import {useNavigate} from "react-router-dom"; -import {PasswordResetEndpoint} from "Frontend/generated/endpoints"; +import {NotificationEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints"; import {toast} from "sonner"; export default function LoginView() { @@ -28,12 +28,17 @@ export default function LoginView() { const [loading, setLoading] = useState(false); const [username, setUsername] = useState(); const [password, setPassword] = useState(); + const [canResetPassword, setCanResetPassword] = useState(false); const [url, setUrl] = useState(); const [resetEmail, setResetEmail] = useState(); const navigate = useNavigate(); - useLayoutEffect(() => { + useEffect(() => { + NotificationEndpoint.isEnabled().then(setCanResetPassword); + }, []); + + useEffect(() => { if (state.user) { const path = url ? new URL(url, document.baseURI).pathname : '/' navigate(path, {replace: true}); @@ -119,28 +124,39 @@ export default function LoginView() { - + {(onClose) => ( <> Request a password reset - { - setResetEmail(event.target.value); - }} - type="email" - placeholder="Email" - /> + {canResetPassword ? + { + setResetEmail(event.target.value); + }} + type="email" + placeholder="Email" + /> : +
+ +

+ Password self-service is disabled.
+ To reset your password please contact your administrator. +

+
+ }
- diff --git a/src/main/frontend/views/PasswordResetView.tsx b/src/main/frontend/views/PasswordResetView.tsx new file mode 100644 index 0000000..e887cca --- /dev/null +++ b/src/main/frontend/views/PasswordResetView.tsx @@ -0,0 +1,92 @@ +import {Button, Card, CardBody, CardHeader} from "@nextui-org/react"; +import {useNavigate, useSearchParams} from "react-router-dom"; +import {Form, Formik} from "formik"; +import Input from "Frontend/components/general/Input"; +import * as Yup from "yup"; +import {PasswordResetEndpoint} from "Frontend/generated/endpoints"; +import React, {useEffect, useState} from "react"; +import {Warning} from "@phosphor-icons/react"; +import {toast} from "sonner"; +import PasswordResetResult from "Frontend/generated/de/grimsi/gameyfin/users/dto/PasswordResetResult"; + +export default function PasswordResetView() { + const [searchParams, setSearchParams] = useSearchParams(); + const [token, setToken] = useState(); + const navigate = useNavigate(); + + useEffect(() => { + let token = searchParams.get("token"); + if (token) setToken(token); + }, [searchParams]); + + async function resetPassword(values: any) { + let token = searchParams.get("token") as string; + let result = await PasswordResetEndpoint.resetPassword(token, values.password) as PasswordResetResult; + + switch (result) { + case PasswordResetResult.SUCCESS: + toast.success("Password reset successfully"); + navigate("/", {replace: true}); + break; + case PasswordResetResult.EXPIRED_TOKEN: + toast.error("Token is expired"); + break; + case PasswordResetResult.INVALID_TOKEN: + default: + toast.error("Token is invalid"); + break + } + } + + return ( +
+ + + Gameyfin Logo + + + {token ? + + {(formik: { values: any; isSubmitting: any; isValid: boolean; }) => ( +
+

Reset your password

+ + + +
+ )} +
+ : +

+ + Invalid token +

+ } +
+
+
+ ); +} \ No newline at end of file diff --git a/src/main/frontend/views/SetupView.tsx b/src/main/frontend/views/SetupView.tsx index 65f7f73..18d3042 100644 --- a/src/main/frontend/views/SetupView.tsx +++ b/src/main/frontend/views/SetupView.tsx @@ -88,8 +88,8 @@ function SetupView() { const navigate = useNavigate(); return ( -
- +
+ 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