Implemented hierarchical roles

Finished password reset flow in frontend
This commit is contained in:
grimsi
2024-09-23 18:40:16 +02:00
parent d0856685f8
commit 8cf6236b1d
19 changed files with 269 additions and 38 deletions
+4
View File
@@ -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: <SetupView/>, handle: {requiresLogin: false}
},
{
path: '/reset-password', element: <PasswordResetView/>, handle: {requiresLogin: false}
}
],
}
+32 -16
View File
@@ -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<string>();
const [password, setPassword] = useState<string>();
const [canResetPassword, setCanResetPassword] = useState(false);
const [url, setUrl] = useState<string>();
const [resetEmail, setResetEmail] = useState<string>();
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() {
</CardBody>
</Card>
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
<ModalContent>
{(onClose) => (
<>
<ModalHeader className="flex flex-col gap-1">Request a password reset</ModalHeader>
<ModalBody>
<Input
onChange={(event: any) => {
setResetEmail(event.target.value);
}}
type="email"
placeholder="Email"
/>
{canResetPassword ?
<Input
onChange={(event: any) => {
setResetEmail(event.target.value);
}}
type="email"
placeholder="Email"
/> :
<div className="flex flex-row items-center gap-4 text-warning">
<WarningCircle size={40}/>
<p>
Password self-service is disabled.<br/>
To reset your password please contact your administrator.
</p>
</div>
}
</ModalBody>
<ModalFooter>
<Button color="danger" variant="light" onPress={onClose}>
Cancel
</Button>
<Button color="primary" onPress={async () => {
await resetPassword();
onClose();
}}>
<Button color="primary"
isDisabled={!canResetPassword}
onPress={async () => {
await resetPassword();
onClose();
}}>
Send request
</Button>
</ModalFooter>
@@ -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<string>();
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 (
<div className="flex flex-row flex-grow items-center justify-center size-full gradient-primary">
<Card className="p-4 min-w-[468px]">
<CardHeader className="mb-4">
<img
className="h-28 w-full content-center"
src="/images/Logo.svg"
alt="Gameyfin Logo"
/>
</CardHeader>
<CardBody>
{token ?
<Formik
initialValues={{
password: "",
passwordRepeat: ""
}}
validationSchema={Yup.object({
password: Yup.string()
.min(8, 'Password must be at least 8 characters long')
.required('Required'),
passwordRepeat: Yup.string()
.equals([Yup.ref('password')], 'Passwords do not match')
.required('Required')
})}
onSubmit={resetPassword}>
{(formik: { values: any; isSubmitting: any; isValid: boolean; }) => (
<Form>
<p className="text-xl text-center mb-8">Reset your password</p>
<Input label="Password" name="password" type="password"
autoComplete="new-password"/>
<Input label="Password (repeat)" name="passwordRepeat" type="password"
autoComplete="new-password"/>
<Button type="submit" className="w-full mt-4" color="primary"
isDisabled={!formik.isValid || formik.isSubmitting}
isLoading={formik.isSubmitting}>
{formik.isSubmitting ? "" : "Reset password"}
</Button>
</Form>
)}
</Formik>
:
<p className="flex flex-row flex-grow justify-center items-center gap-2 text-danger text-2xl font-bold">
<Warning weight="fill"/>
Invalid token
</p>
}
</CardBody>
</Card>
</div>
);
}
+2 -2
View File
@@ -88,8 +88,8 @@ function SetupView() {
const navigate = useNavigate();
return (
<div className="flex size-full gradient-primary">
<Card className="w-3/4 h-3/4 min-w-[500px] m-auto p-8">
<div className="flex flex-row flex-grow items-center justify-center gradient-primary">
<Card className="w-3/4 h-3/4 min-w-[500px] p-8">
<Wizard
initialValues={{username: '', email: '', password: '', passwordRepeat: ''}}
onSubmit={
@@ -8,7 +8,7 @@ import jakarta.annotation.security.PermitAll
import jakarta.annotation.security.RolesAllowed
@Endpoint
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
@RolesAllowed(Roles.Names.ADMIN)
class ConfigEndpoint(
private val config: ConfigService
) {
@@ -0,0 +1,25 @@
package de.grimsi.gameyfin.core.security
import de.grimsi.gameyfin.users.UserService
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.access.hierarchicalroles.RoleHierarchy
import org.springframework.security.access.hierarchicalroles.RoleHierarchyAuthoritiesMapper
import org.springframework.security.authentication.dao.DaoAuthenticationProvider
import org.springframework.security.crypto.password.PasswordEncoder
@Configuration
class AuthenticationProviderConfig {
@Bean
fun hierarchicalUserAuthenticationProvider(
userService: UserService,
roleHierarchy: RoleHierarchy,
passwordEncoder: PasswordEncoder
): DaoAuthenticationProvider {
val provider = DaoAuthenticationProvider()
provider.setUserDetailsService(userService)
provider.setPasswordEncoder(passwordEncoder)
provider.setAuthoritiesMapper(RoleHierarchyAuthoritiesMapper(roleHierarchy))
return provider
}
}
@@ -0,0 +1,17 @@
package de.grimsi.gameyfin.core.security
import org.springframework.context.annotation.Bean
import org.springframework.context.annotation.Configuration
import org.springframework.security.access.hierarchicalroles.RoleHierarchy
import org.springframework.security.access.hierarchicalroles.RoleHierarchyImpl
@Configuration
class RoleHierarchyConfig {
@Bean
fun roleHierarchy(): RoleHierarchy {
return RoleHierarchyImpl.fromHierarchy(
"""ROLE_SUPERADMIN > ROLE_ADMIN
|ROLE_ADMIN > ROLE_USER""".trimMargin()
)
}
}
@@ -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<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry ->
auth.requestMatchers("/setup").permitAll()
.requestMatchers("/reset-password").permitAll()
.requestMatchers("/public/**").permitAll()
.requestMatchers("/images/**").permitAll()
}
@@ -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<Library> {
return libraryService.getAllLibraries()
}
@@ -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
) {
@@ -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<String, Any>): Boolean {
return notificationService.testCredentials(provider, credentials)
}
@@ -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
@@ -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
@@ -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()
}
@@ -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)
}
}
@@ -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
}
}
@@ -23,7 +23,7 @@ class UserEndpoint(
return userService.getUserInfo(auth)
}
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
@RolesAllowed(Roles.Names.ADMIN)
fun getAllUsers(): List<UserInfoDto> {
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)
}
@@ -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)
@@ -0,0 +1,5 @@
package de.grimsi.gameyfin.users.dto
enum class PasswordResetResult() {
SUCCESS, INVALID_TOKEN, EXPIRED_TOKEN
}