mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-17 00:30:04 +00:00
Implemented hierarchical roles
Finished password reset flow in frontend
This commit is contained in:
@@ -14,6 +14,7 @@ import {AdministrationView} from "Frontend/views/AdministrationView";
|
|||||||
import {ProfileView} from "Frontend/views/ProfileView";
|
import {ProfileView} from "Frontend/views/ProfileView";
|
||||||
import {NotificationManagement} from "Frontend/components/administration/NotificationManagement";
|
import {NotificationManagement} from "Frontend/components/administration/NotificationManagement";
|
||||||
import {LogManagement} from "Frontend/components/administration/LogManagement";
|
import {LogManagement} from "Frontend/components/administration/LogManagement";
|
||||||
|
import PasswordResetView from "Frontend/views/PasswordResetView";
|
||||||
|
|
||||||
export const routes = protectRoutes([
|
export const routes = protectRoutes([
|
||||||
{
|
{
|
||||||
@@ -53,6 +54,9 @@ export const routes = protectRoutes([
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
path: '/setup', element: <SetupView/>, handle: {requiresLogin: false}
|
path: '/setup', element: <SetupView/>, handle: {requiresLogin: false}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: '/reset-password', element: <PasswordResetView/>, handle: {requiresLogin: false}
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import {useAuth} from "Frontend/util/auth";
|
import {useAuth} from "Frontend/util/auth";
|
||||||
import {useLayoutEffect, useState} from "react";
|
import {useEffect, useState} from "react";
|
||||||
import {XCircle} from "@phosphor-icons/react";
|
import {WarningCircle, XCircle} from "@phosphor-icons/react";
|
||||||
import {
|
import {
|
||||||
Button,
|
Button,
|
||||||
Card,
|
Card,
|
||||||
@@ -17,7 +17,7 @@ import {
|
|||||||
} from "@nextui-org/react";
|
} from "@nextui-org/react";
|
||||||
import {Alert, AlertDescription, AlertTitle} from "Frontend/@/components/ui/alert";
|
import {Alert, AlertDescription, AlertTitle} from "Frontend/@/components/ui/alert";
|
||||||
import {useNavigate} from "react-router-dom";
|
import {useNavigate} from "react-router-dom";
|
||||||
import {PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
import {NotificationEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
|
|
||||||
export default function LoginView() {
|
export default function LoginView() {
|
||||||
@@ -28,12 +28,17 @@ export default function LoginView() {
|
|||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [username, setUsername] = useState<string>();
|
const [username, setUsername] = useState<string>();
|
||||||
const [password, setPassword] = useState<string>();
|
const [password, setPassword] = useState<string>();
|
||||||
|
const [canResetPassword, setCanResetPassword] = useState(false);
|
||||||
const [url, setUrl] = useState<string>();
|
const [url, setUrl] = useState<string>();
|
||||||
const [resetEmail, setResetEmail] = useState<string>();
|
const [resetEmail, setResetEmail] = useState<string>();
|
||||||
|
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
useEffect(() => {
|
||||||
|
NotificationEndpoint.isEnabled().then(setCanResetPassword);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
if (state.user) {
|
if (state.user) {
|
||||||
const path = url ? new URL(url, document.baseURI).pathname : '/'
|
const path = url ? new URL(url, document.baseURI).pathname : '/'
|
||||||
navigate(path, {replace: true});
|
navigate(path, {replace: true});
|
||||||
@@ -119,28 +124,39 @@ export default function LoginView() {
|
|||||||
</CardBody>
|
</CardBody>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange}>
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="xl">
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
{(onClose) => (
|
{(onClose) => (
|
||||||
<>
|
<>
|
||||||
<ModalHeader className="flex flex-col gap-1">Request a password reset</ModalHeader>
|
<ModalHeader className="flex flex-col gap-1">Request a password reset</ModalHeader>
|
||||||
<ModalBody>
|
<ModalBody>
|
||||||
<Input
|
{canResetPassword ?
|
||||||
onChange={(event: any) => {
|
<Input
|
||||||
setResetEmail(event.target.value);
|
onChange={(event: any) => {
|
||||||
}}
|
setResetEmail(event.target.value);
|
||||||
type="email"
|
}}
|
||||||
placeholder="Email"
|
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>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button color="danger" variant="light" onPress={onClose}>
|
<Button color="danger" variant="light" onPress={onClose}>
|
||||||
Cancel
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="primary" onPress={async () => {
|
<Button color="primary"
|
||||||
await resetPassword();
|
isDisabled={!canResetPassword}
|
||||||
onClose();
|
onPress={async () => {
|
||||||
}}>
|
await resetPassword();
|
||||||
|
onClose();
|
||||||
|
}}>
|
||||||
Send request
|
Send request
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -88,8 +88,8 @@ function SetupView() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="flex size-full gradient-primary">
|
<div className="flex flex-row flex-grow items-center justify-center gradient-primary">
|
||||||
<Card className="w-3/4 h-3/4 min-w-[500px] m-auto p-8">
|
<Card className="w-3/4 h-3/4 min-w-[500px] p-8">
|
||||||
<Wizard
|
<Wizard
|
||||||
initialValues={{username: '', email: '', password: '', passwordRepeat: ''}}
|
initialValues={{username: '', email: '', password: '', passwordRepeat: ''}}
|
||||||
onSubmit={
|
onSubmit={
|
||||||
|
|||||||
@@ -8,7 +8,7 @@ import jakarta.annotation.security.PermitAll
|
|||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
@RolesAllowed(Roles.Names.ADMIN)
|
||||||
class ConfigEndpoint(
|
class ConfigEndpoint(
|
||||||
private val config: ConfigService
|
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
|
// Configure your static resources with public access before calling super.configure(HttpSecurity) as it adds final anyRequest matcher
|
||||||
http.authorizeHttpRequests { auth: AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry ->
|
http.authorizeHttpRequests { auth: AuthorizeHttpRequestsConfigurer<HttpSecurity>.AuthorizationManagerRequestMatcherRegistry ->
|
||||||
auth.requestMatchers("/setup").permitAll()
|
auth.requestMatchers("/setup").permitAll()
|
||||||
|
.requestMatchers("/reset-password").permitAll()
|
||||||
.requestMatchers("/public/**").permitAll()
|
.requestMatchers("/public/**").permitAll()
|
||||||
.requestMatchers("/images/**").permitAll()
|
.requestMatchers("/images/**").permitAll()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,15 +1,15 @@
|
|||||||
package de.grimsi.gameyfin.libraries
|
package de.grimsi.gameyfin.libraries
|
||||||
|
|
||||||
import com.vaadin.hilla.Endpoint
|
import com.vaadin.hilla.Endpoint
|
||||||
import de.grimsi.gameyfin.libraries.entities.Library
|
|
||||||
import de.grimsi.gameyfin.core.Roles
|
import de.grimsi.gameyfin.core.Roles
|
||||||
|
import de.grimsi.gameyfin.libraries.entities.Library
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
class LibraryEndpoint(
|
class LibraryEndpoint(
|
||||||
private val libraryService: LibraryService
|
private val libraryService: LibraryService
|
||||||
) {
|
) {
|
||||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
@RolesAllowed(Roles.Names.ADMIN)
|
||||||
fun getAllLibraries(): Collection<Library> {
|
fun getAllLibraries(): Collection<Library> {
|
||||||
return libraryService.getAllLibraries()
|
return libraryService.getAllLibraries()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import jakarta.annotation.security.RolesAllowed
|
|||||||
import reactor.core.publisher.Flux
|
import reactor.core.publisher.Flux
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
@RolesAllowed(Roles.Names.ADMIN)
|
||||||
class LogEndpoint(
|
class LogEndpoint(
|
||||||
private val logService: LogService
|
private val logService: LogService
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -1,15 +1,21 @@
|
|||||||
package de.grimsi.gameyfin.notifications
|
package de.grimsi.gameyfin.notifications
|
||||||
|
|
||||||
|
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||||
import com.vaadin.hilla.Endpoint
|
import com.vaadin.hilla.Endpoint
|
||||||
import de.grimsi.gameyfin.core.Roles
|
import de.grimsi.gameyfin.core.Roles
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
@RolesAllowed(Roles.Names.ADMIN)
|
||||||
class NotificationEndpoint(
|
class NotificationEndpoint(
|
||||||
private val notificationService: NotificationService
|
private val notificationService: NotificationService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
|
@AnonymousAllowed
|
||||||
|
fun isEnabled(): Boolean {
|
||||||
|
return notificationService.enabled
|
||||||
|
}
|
||||||
|
|
||||||
fun verifyCredentials(provider: String, credentials: Map<String, Any>): Boolean {
|
fun verifyCredentials(provider: String, credentials: Map<String, Any>): Boolean {
|
||||||
return notificationService.testCredentials(provider, credentials)
|
return notificationService.testCredentials(provider, credentials)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -42,6 +42,12 @@ class NotificationService(
|
|||||||
@Async
|
@Async
|
||||||
@EventListener(PasswordResetRequestEvent::class)
|
@EventListener(PasswordResetRequestEvent::class)
|
||||||
fun onPasswordResetRequest(event: PasswordResetRequestEvent) {
|
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" }
|
log.info { "Sending password reset request notification" }
|
||||||
|
|
||||||
val token = event.token
|
val token = event.token
|
||||||
|
|||||||
+1
-1
@@ -4,7 +4,7 @@ import com.vaadin.hilla.Endpoint
|
|||||||
import de.grimsi.gameyfin.core.Roles
|
import de.grimsi.gameyfin.core.Roles
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
|
||||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
@RolesAllowed(Roles.Names.ADMIN)
|
||||||
@Endpoint
|
@Endpoint
|
||||||
class MessageTemplateEndpoint(
|
class MessageTemplateEndpoint(
|
||||||
private val messageTemplateService: MessageTemplateService
|
private val messageTemplateService: MessageTemplateService
|
||||||
|
|||||||
@@ -9,7 +9,7 @@ class SystemEndpoint(
|
|||||||
private val systemService: SystemService
|
private val systemService: SystemService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
@RolesAllowed(Roles.Names.ADMIN)
|
||||||
fun restart() {
|
fun restart() {
|
||||||
systemService.restart()
|
systemService.restart()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -2,6 +2,9 @@ package de.grimsi.gameyfin.users
|
|||||||
|
|
||||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||||
import com.vaadin.hilla.Endpoint
|
import com.vaadin.hilla.Endpoint
|
||||||
|
import de.grimsi.gameyfin.core.Roles
|
||||||
|
import de.grimsi.gameyfin.users.dto.PasswordResetResult
|
||||||
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
@AnonymousAllowed
|
@AnonymousAllowed
|
||||||
@@ -11,9 +14,16 @@ class PasswordResetEndpoint(
|
|||||||
|
|
||||||
fun requestPasswordReset(email: String) {
|
fun requestPasswordReset(email: String) {
|
||||||
passwordResetService.requestPasswordReset(email)
|
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.Utils
|
||||||
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
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.PasswordResetToken
|
||||||
import de.grimsi.gameyfin.users.entities.User
|
import de.grimsi.gameyfin.users.entities.User
|
||||||
import de.grimsi.gameyfin.users.persistence.PasswordResetTokenRepository
|
import de.grimsi.gameyfin.users.persistence.PasswordResetTokenRepository
|
||||||
@@ -18,6 +20,7 @@ import kotlin.time.toJavaDuration
|
|||||||
class PasswordResetService(
|
class PasswordResetService(
|
||||||
private val userService: UserService,
|
private val userService: UserService,
|
||||||
private val sessionService: SessionService,
|
private val sessionService: SessionService,
|
||||||
|
private val notificationService: NotificationService,
|
||||||
private val eventPublisher: ApplicationEventPublisher,
|
private val eventPublisher: ApplicationEventPublisher,
|
||||||
private val passwordResetTokenRepository: PasswordResetTokenRepository
|
private val passwordResetTokenRepository: PasswordResetTokenRepository
|
||||||
) {
|
) {
|
||||||
@@ -36,23 +39,46 @@ class PasswordResetService(
|
|||||||
private val baseUrl: String
|
private val baseUrl: String
|
||||||
get() = Utils.getBaseUrl()
|
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) {
|
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)
|
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) {
|
||||||
if (user != null && user.emailConfirmed && user.oidcProviderId == null) {
|
log.error { "No user with email '${maskedEmail}' found" }
|
||||||
val token = createPasswordResetToken(user)
|
return
|
||||||
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, baseUrl))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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
|
// Simulate a delay to prevent timing attacks
|
||||||
Thread.sleep(secureRandom.nextLong(1024))
|
Thread.sleep(secureRandom.nextLong(1024))
|
||||||
}
|
}
|
||||||
|
|
||||||
fun createPasswordResetToken(user: User): PasswordResetToken {
|
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(
|
val token = PasswordResetToken(
|
||||||
user = user,
|
user = user,
|
||||||
token = UUID.randomUUID().toString()
|
token = UUID.randomUUID().toString()
|
||||||
@@ -65,13 +91,35 @@ class PasswordResetService(
|
|||||||
return passwordResetTokenRepository.save(token)
|
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 =
|
val passwordResetToken =
|
||||||
passwordResetTokenRepository.findByToken(token)
|
passwordResetTokenRepository.findByToken(token)
|
||||||
?: throw IllegalArgumentException("Token not found")
|
?: return PasswordResetResult.INVALID_TOKEN
|
||||||
|
|
||||||
if (passwordResetToken.isExpired) {
|
if (passwordResetToken.isExpired) {
|
||||||
throw IllegalStateException("Token is expired")
|
return PasswordResetResult.EXPIRED_TOKEN
|
||||||
}
|
}
|
||||||
|
|
||||||
val user = passwordResetToken.user
|
val user = passwordResetToken.user
|
||||||
@@ -79,5 +127,6 @@ class PasswordResetService(
|
|||||||
userService.updatePassword(user, newPassword)
|
userService.updatePassword(user, newPassword)
|
||||||
passwordResetTokenRepository.delete(passwordResetToken)
|
passwordResetTokenRepository.delete(passwordResetToken)
|
||||||
sessionService.logoutAllSessions(user)
|
sessionService.logoutAllSessions(user)
|
||||||
|
return PasswordResetResult.SUCCESS
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -23,7 +23,7 @@ class UserEndpoint(
|
|||||||
return userService.getUserInfo(auth)
|
return userService.getUserInfo(auth)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
@RolesAllowed(Roles.Names.ADMIN)
|
||||||
fun getAllUsers(): List<UserInfoDto> {
|
fun getAllUsers(): List<UserInfoDto> {
|
||||||
return userService.getAllUsers()
|
return userService.getAllUsers()
|
||||||
}
|
}
|
||||||
@@ -40,7 +40,7 @@ class UserEndpoint(
|
|||||||
userService.updateUser(auth.name, updates)
|
userService.updateUser(auth.name, updates)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
@RolesAllowed(Roles.Names.ADMIN)
|
||||||
fun updateUserByName(username: String, updates: UserUpdateDto) {
|
fun updateUserByName(username: String, updates: UserUpdateDto) {
|
||||||
userService.updateUser(username, updates)
|
userService.updateUser(username, updates)
|
||||||
}
|
}
|
||||||
@@ -51,7 +51,7 @@ class UserEndpoint(
|
|||||||
userService.deleteUser(auth.name)
|
userService.deleteUser(auth.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
@RolesAllowed(Roles.Names.ADMIN)
|
||||||
fun deleteUserByName(username: String) {
|
fun deleteUserByName(username: String) {
|
||||||
userService.deleteUser(username)
|
userService.deleteUser(username)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ class AvatarController(
|
|||||||
userService.deleteAvatar(auth.name)
|
userService.deleteAvatar(auth.name)
|
||||||
}
|
}
|
||||||
|
|
||||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
@RolesAllowed(Roles.Names.ADMIN)
|
||||||
@PostMapping("/avatar/deleteByName")
|
@PostMapping("/avatar/deleteByName")
|
||||||
fun deleteAvatarByName(@RequestParam("name") name: String) {
|
fun deleteAvatarByName(@RequestParam("name") name: String) {
|
||||||
userService.deleteAvatar(name)
|
userService.deleteAvatar(name)
|
||||||
|
|||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package de.grimsi.gameyfin.users.dto
|
||||||
|
|
||||||
|
enum class PasswordResetResult() {
|
||||||
|
SUCCESS, INVALID_TOKEN, EXPIRED_TOKEN
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user