mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-16 00:30:02 +00:00
Implemented generic token handling
This commit is contained in:
+24
-24
@@ -3,14 +3,14 @@ import withConfigPage from "Frontend/components/administration/withConfigPage";
|
|||||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||||
import Section from "Frontend/components/general/Section";
|
import Section from "Frontend/components/general/Section";
|
||||||
import {Button, Card, Tooltip, useDisclosure} from "@nextui-org/react";
|
import {Button, Card, Tooltip, useDisclosure} from "@nextui-org/react";
|
||||||
import {MessageTemplateEndpoint, NotificationEndpoint} from "Frontend/generated/endpoints";
|
import {MessageEndpoint, MessageTemplateEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {PaperPlaneRight, Pencil} from "@phosphor-icons/react";
|
import {PaperPlaneRight, Pencil} from "@phosphor-icons/react";
|
||||||
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
|
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto";
|
||||||
import SendTestNotificationModal from "Frontend/components/administration/notifications/SendTestNotificationModal";
|
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
|
||||||
import EditTemplateModal from "Frontend/components/administration/notifications/EditTemplateModel";
|
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
|
||||||
|
|
||||||
function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
function MessageManagementLayout({getConfig, getConfigs, formik}: any) {
|
||||||
|
|
||||||
const editorModal = useDisclosure();
|
const editorModal = useDisclosure();
|
||||||
const testNotificationModal = useDisclosure();
|
const testNotificationModal = useDisclosure();
|
||||||
@@ -25,13 +25,13 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
|||||||
|
|
||||||
async function verifyCredentials(provider: string) {
|
async function verifyCredentials(provider: string) {
|
||||||
const credentials: Record<string, any> = {
|
const credentials: Record<string, any> = {
|
||||||
host: formik.values.notifications.providers.email.host,
|
host: formik.values.messages.providers.email.host,
|
||||||
port: formik.values.notifications.providers.email.port,
|
port: formik.values.messages.providers.email.port,
|
||||||
username: formik.values.notifications.providers.email.username,
|
username: formik.values.messages.providers.email.username,
|
||||||
password: formik.values.notifications.providers.email.password
|
password: formik.values.messages.providers.email.password
|
||||||
}
|
}
|
||||||
|
|
||||||
const areCredentialsValid = await NotificationEndpoint.verifyCredentials(provider, credentials);
|
const areCredentialsValid = await MessageEndpoint.verifyCredentials(provider, credentials);
|
||||||
|
|
||||||
if (areCredentialsValid) {
|
if (areCredentialsValid) {
|
||||||
toast.success("Credentials are valid")
|
toast.success("Credentials are valid")
|
||||||
@@ -57,22 +57,22 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
|||||||
<div className="flex flex-row gap-8">
|
<div className="flex flex-row gap-8">
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1">
|
||||||
<Section title="E-Mail"/>
|
<Section title="E-Mail"/>
|
||||||
<ConfigFormField configElement={getConfig("notifications.providers.email.enabled")}/>
|
<ConfigFormField configElement={getConfig("messages.providers.email.enabled")}/>
|
||||||
<ConfigFormField configElement={getConfig("notifications.providers.email.host")}
|
<ConfigFormField configElement={getConfig("messages.providers.email.host")}
|
||||||
isDisabled={!formik.values.notifications.providers.email.enabled}/>
|
isDisabled={!formik.values.messages.providers.email.enabled}/>
|
||||||
<ConfigFormField configElement={getConfig("notifications.providers.email.port")}
|
<ConfigFormField configElement={getConfig("messages.providers.email.port")}
|
||||||
isDisabled={!formik.values.notifications.providers.email.enabled}/>
|
isDisabled={!formik.values.messages.providers.email.enabled}/>
|
||||||
<ConfigFormField configElement={getConfig("notifications.providers.email.username")}
|
<ConfigFormField configElement={getConfig("messages.providers.email.username")}
|
||||||
isDisabled={!formik.values.notifications.providers.email.enabled}/>
|
isDisabled={!formik.values.messages.providers.email.enabled}/>
|
||||||
<ConfigFormField configElement={getConfig("notifications.providers.email.password")}
|
<ConfigFormField configElement={getConfig("messages.providers.email.password")}
|
||||||
type="password"
|
type="password"
|
||||||
isDisabled={!formik.values.notifications.providers.email.enabled}/>
|
isDisabled={!formik.values.messages.providers.email.enabled}/>
|
||||||
<Button onPress={() => verifyCredentials("email")}
|
<Button onPress={() => verifyCredentials("email")}
|
||||||
isDisabled={!(
|
isDisabled={!(
|
||||||
formik.values.notifications.providers.email.enabled &&
|
formik.values.messages.providers.email.enabled &&
|
||||||
formik.values.notifications.providers.email.host &&
|
formik.values.messages.providers.email.host &&
|
||||||
formik.values.notifications.providers.email.port &&
|
formik.values.messages.providers.email.port &&
|
||||||
formik.values.notifications.providers.email.username)}>Test</Button>
|
formik.values.messages.providers.email.username)}>Test</Button>
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col flex-1">
|
<div className="flex flex-col flex-1">
|
||||||
<Section title="Message Templates"/>
|
<Section title="Message Templates"/>
|
||||||
@@ -119,4 +119,4 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export const NotificationManagement = withConfigPage(NotificationManagementLayout, "Notifications", "notifications");
|
export const MessageManagement = withConfigPage(MessageManagementLayout, "Messages", "messages");
|
||||||
+18
-10
@@ -12,8 +12,8 @@ import {
|
|||||||
} from "@nextui-org/react";
|
} from "@nextui-org/react";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import {MessageTemplateEndpoint} from "Frontend/generated/endpoints";
|
import {MessageTemplateEndpoint} from "Frontend/generated/endpoints";
|
||||||
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
|
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto";
|
||||||
import TemplateType from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/TemplateType";
|
import TemplateType from "Frontend/generated/de/grimsi/gameyfin/messages/templates/TemplateType";
|
||||||
|
|
||||||
interface EditTemplateModalProps {
|
interface EditTemplateModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -41,6 +41,12 @@ export default function EditTemplateModal({isOpen, onOpenChange, selectedTemplat
|
|||||||
await MessageTemplateEndpoint.save(template.key, TemplateType.MJML, templateContent);
|
await MessageTemplateEndpoint.save(template.key, TemplateType.MJML, templateContent);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function templateContainsAllRequiredPlaceholders(): boolean {
|
||||||
|
if (!selectedTemplate || !selectedTemplate.availablePlaceholders) return false;
|
||||||
|
return selectedTemplate.availablePlaceholders
|
||||||
|
.every((p) => templateContent.includes(`{${p}}`))
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="5xl">
|
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="5xl">
|
||||||
<ModalContent>
|
<ModalContent>
|
||||||
@@ -98,15 +104,17 @@ export default function EditTemplateModal({isOpen, onOpenChange, selectedTemplat
|
|||||||
</ModalBody>
|
</ModalBody>
|
||||||
<ModalFooter>
|
<ModalFooter>
|
||||||
<Button color="danger" variant="light" onPress={onClose}>
|
<Button color="danger" variant="light" onPress={onClose}>
|
||||||
Close
|
Cancel
|
||||||
</Button>
|
</Button>
|
||||||
<Button color="primary" onPress={async () => {
|
<Button color="primary"
|
||||||
if (selectedTemplate) {
|
isDisabled={!templateContainsAllRequiredPlaceholders()}
|
||||||
await saveTemplate(selectedTemplate);
|
onPress={async () => {
|
||||||
toast.success("Template saved");
|
if (selectedTemplate) {
|
||||||
onClose();
|
await saveTemplate(selectedTemplate);
|
||||||
}
|
toast.success("Template saved");
|
||||||
}}>
|
onClose();
|
||||||
|
}
|
||||||
|
}}>
|
||||||
Save
|
Save
|
||||||
</Button>
|
</Button>
|
||||||
</ModalFooter>
|
</ModalFooter>
|
||||||
+3
-3
@@ -3,9 +3,9 @@ import {Form, Formik} from "formik";
|
|||||||
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
|
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import Input from "Frontend/components/general/Input";
|
import Input from "Frontend/components/general/Input";
|
||||||
import {NotificationEndpoint} from "Frontend/generated/endpoints";
|
import {MessageEndpoint} from "Frontend/generated/endpoints";
|
||||||
import * as Yup from "yup";
|
import * as Yup from "yup";
|
||||||
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
|
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto";
|
||||||
|
|
||||||
interface SendTestNotificationModalProps {
|
interface SendTestNotificationModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
@@ -35,7 +35,7 @@ export default function SendTestNotificationModal({
|
|||||||
<Formik
|
<Formik
|
||||||
initialValues={{}}
|
initialValues={{}}
|
||||||
onSubmit={async (values) => {
|
onSubmit={async (values) => {
|
||||||
await NotificationEndpoint.sendTestNotification(selectedTemplate?.key, values);
|
await MessageEndpoint.sendTestNotification(selectedTemplate?.key, values);
|
||||||
toast.success("Test notification to you has been sent");
|
toast.success("Test notification to you has been sent");
|
||||||
onClose();
|
onClose();
|
||||||
}}
|
}}
|
||||||
@@ -12,7 +12,7 @@ import ProfileManagement from "Frontend/components/administration/ProfileManagem
|
|||||||
import {SsoManagement} from "Frontend/components/administration/SsoManagement";
|
import {SsoManagement} from "Frontend/components/administration/SsoManagement";
|
||||||
import {AdministrationView} from "Frontend/views/AdministrationView";
|
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 {MessageManagement} from "Frontend/components/administration/MessageManagement";
|
||||||
import {LogManagement} from "Frontend/components/administration/LogManagement";
|
import {LogManagement} from "Frontend/components/administration/LogManagement";
|
||||||
import PasswordResetView from "Frontend/views/PasswordResetView";
|
import PasswordResetView from "Frontend/views/PasswordResetView";
|
||||||
|
|
||||||
@@ -43,7 +43,7 @@ export const routes = protectRoutes([
|
|||||||
{path: 'libraries', element: <LibraryManagement/>},
|
{path: 'libraries', element: <LibraryManagement/>},
|
||||||
{path: 'users', element: <UserManagement/>},
|
{path: 'users', element: <UserManagement/>},
|
||||||
{path: 'sso', element: <SsoManagement/>},
|
{path: 'sso', element: <SsoManagement/>},
|
||||||
{path: 'notifications', element: <NotificationManagement/>},
|
{path: 'messages', element: <MessageManagement/>},
|
||||||
{path: 'logs', element: <LogManagement/>}
|
{path: 'logs', element: <LogManagement/>}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -18,8 +18,8 @@ const menuItems: MenuItem[] = [
|
|||||||
icon: <LockKey/>
|
icon: <LockKey/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
title: "Notifications",
|
title: "Messages",
|
||||||
url: "notifications",
|
url: "messages",
|
||||||
icon: <Envelope/>
|
icon: <Envelope/>
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 {NotificationEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
|
|
||||||
export default function LoginView() {
|
export default function LoginView() {
|
||||||
@@ -35,7 +35,7 @@ export default function LoginView() {
|
|||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
NotificationEndpoint.isEnabled().then(setCanResetPassword);
|
MessageEndpoint.isEnabled().then(setCanResetPassword);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ import {PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
|||||||
import React, {useEffect, useState} from "react";
|
import React, {useEffect, useState} from "react";
|
||||||
import {Warning} from "@phosphor-icons/react";
|
import {Warning} from "@phosphor-icons/react";
|
||||||
import {toast} from "sonner";
|
import {toast} from "sonner";
|
||||||
import PasswordResetResult from "Frontend/generated/de/grimsi/gameyfin/users/dto/PasswordResetResult";
|
import TokenValidationResult from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenValidationResult";
|
||||||
|
|
||||||
export default function PasswordResetView() {
|
export default function PasswordResetView() {
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
const [searchParams, setSearchParams] = useSearchParams();
|
||||||
@@ -21,17 +21,17 @@ export default function PasswordResetView() {
|
|||||||
|
|
||||||
async function resetPassword(values: any) {
|
async function resetPassword(values: any) {
|
||||||
let token = searchParams.get("token") as string;
|
let token = searchParams.get("token") as string;
|
||||||
let result = await PasswordResetEndpoint.resetPassword(token, values.password) as PasswordResetResult;
|
let result = await PasswordResetEndpoint.resetPassword(token, values.password) as TokenValidationResult;
|
||||||
|
|
||||||
switch (result) {
|
switch (result) {
|
||||||
case PasswordResetResult.SUCCESS:
|
case TokenValidationResult.VALID:
|
||||||
toast.success("Password reset successfully");
|
toast.success("Password reset successfully");
|
||||||
navigate("/", {replace: true});
|
navigate("/", {replace: true});
|
||||||
break;
|
break;
|
||||||
case PasswordResetResult.EXPIRED_TOKEN:
|
case TokenValidationResult.EXPIRED:
|
||||||
toast.error("Token is expired");
|
toast.error("Token is expired");
|
||||||
break;
|
break;
|
||||||
case PasswordResetResult.INVALID_TOKEN:
|
case TokenValidationResult.INVALID:
|
||||||
default:
|
default:
|
||||||
toast.error("Token is invalid");
|
toast.error("Token is invalid");
|
||||||
break
|
break
|
||||||
|
|||||||
@@ -141,39 +141,39 @@ sealed class ConfigProperties<T : Serializable>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Notifications */
|
/** Messages */
|
||||||
sealed class Notifications {
|
sealed class Messages {
|
||||||
sealed class Providers {
|
sealed class Providers {
|
||||||
sealed class Email {
|
sealed class Email {
|
||||||
data object Enabled : ConfigProperties<Boolean>(
|
data object Enabled : ConfigProperties<Boolean>(
|
||||||
Boolean::class,
|
Boolean::class,
|
||||||
"notifications.providers.email.enabled",
|
"messages.providers.email.enabled",
|
||||||
"Enable E-Mail notifications",
|
"Enable E-Mail notifications",
|
||||||
false
|
false
|
||||||
)
|
)
|
||||||
|
|
||||||
data object Host : ConfigProperties<String>(
|
data object Host : ConfigProperties<String>(
|
||||||
String::class,
|
String::class,
|
||||||
"notifications.providers.email.host",
|
"messages.providers.email.host",
|
||||||
"URL of the email server"
|
"URL of the email server"
|
||||||
)
|
)
|
||||||
|
|
||||||
data object Port : ConfigProperties<Int>(
|
data object Port : ConfigProperties<Int>(
|
||||||
Int::class,
|
Int::class,
|
||||||
"notifications.providers.email.port",
|
"messages.providers.email.port",
|
||||||
"Port of the email server",
|
"Port of the email server",
|
||||||
587
|
587
|
||||||
)
|
)
|
||||||
|
|
||||||
data object Username : ConfigProperties<String>(
|
data object Username : ConfigProperties<String>(
|
||||||
String::class,
|
String::class,
|
||||||
"notifications.providers.email.username",
|
"messages.providers.email.username",
|
||||||
"Username for the email account"
|
"Username for the email account"
|
||||||
)
|
)
|
||||||
|
|
||||||
data object Password : ConfigProperties<String>(
|
data object Password : ConfigProperties<String>(
|
||||||
String::class,
|
String::class,
|
||||||
"notifications.providers.email.password",
|
"messages.providers.email.password",
|
||||||
"Password for the email account"
|
"Password for the email account"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,13 +1,14 @@
|
|||||||
package de.grimsi.gameyfin.core.events
|
package de.grimsi.gameyfin.core.events
|
||||||
|
|
||||||
import de.grimsi.gameyfin.users.entities.PasswordResetToken
|
import de.grimsi.gameyfin.shared.token.Token
|
||||||
|
import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset
|
||||||
import org.springframework.context.ApplicationEvent
|
import org.springframework.context.ApplicationEvent
|
||||||
|
|
||||||
class UserInvitationEvent(source: Any) : ApplicationEvent(source)
|
class UserInvitationEvent(source: Any) : ApplicationEvent(source)
|
||||||
|
|
||||||
class UserRegistrationEvent(source: Any) : ApplicationEvent(source)
|
class UserRegistrationEvent(source: Any) : ApplicationEvent(source)
|
||||||
|
|
||||||
class PasswordResetRequestEvent(source: Any, val token: PasswordResetToken, val baseUrl: String) :
|
class PasswordResetRequestEvent(source: Any, val token: Token<PasswordReset>, val baseUrl: String) :
|
||||||
ApplicationEvent(source)
|
ApplicationEvent(source)
|
||||||
|
|
||||||
class GameRequestEvent(source: Any) : ApplicationEvent(source)
|
class GameRequestEvent(source: Any) : ApplicationEvent(source)
|
||||||
|
|||||||
+6
-6
@@ -1,4 +1,4 @@
|
|||||||
package de.grimsi.gameyfin.notifications
|
package de.grimsi.gameyfin.messages
|
||||||
|
|
||||||
import com.vaadin.flow.server.auth.AnonymousAllowed
|
import com.vaadin.flow.server.auth.AnonymousAllowed
|
||||||
import com.vaadin.hilla.Endpoint
|
import com.vaadin.hilla.Endpoint
|
||||||
@@ -7,20 +7,20 @@ import jakarta.annotation.security.RolesAllowed
|
|||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
@RolesAllowed(Roles.Names.ADMIN)
|
@RolesAllowed(Roles.Names.ADMIN)
|
||||||
class NotificationEndpoint(
|
class MessageEndpoint(
|
||||||
private val notificationService: NotificationService
|
private val messageService: MessageService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
@AnonymousAllowed
|
@AnonymousAllowed
|
||||||
fun isEnabled(): Boolean {
|
fun isEnabled(): Boolean {
|
||||||
return notificationService.enabled
|
return messageService.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 messageService.testCredentials(provider, credentials)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun sendTestNotification(templateKey: String, placeholders: Map<String, String>): Boolean {
|
fun sendTestNotification(templateKey: String, placeholders: Map<String, String>): Boolean {
|
||||||
return notificationService.sendTestNotification(templateKey, placeholders)
|
return messageService.sendTestNotification(templateKey, placeholders)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
+8
-8
@@ -1,9 +1,9 @@
|
|||||||
package de.grimsi.gameyfin.notifications
|
package de.grimsi.gameyfin.messages
|
||||||
|
|
||||||
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
||||||
import de.grimsi.gameyfin.notifications.providers.AbstractNotificationProvider
|
import de.grimsi.gameyfin.messages.providers.AbstractMessageProvider
|
||||||
import de.grimsi.gameyfin.notifications.templates.MessageTemplateService
|
import de.grimsi.gameyfin.messages.templates.MessageTemplateService
|
||||||
import de.grimsi.gameyfin.notifications.templates.MessageTemplates
|
import de.grimsi.gameyfin.messages.templates.MessageTemplates
|
||||||
import de.grimsi.gameyfin.users.UserService
|
import de.grimsi.gameyfin.users.UserService
|
||||||
import io.github.oshai.kotlinlogging.KLogger
|
import io.github.oshai.kotlinlogging.KLogger
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
@@ -18,7 +18,7 @@ import java.util.*
|
|||||||
|
|
||||||
@EnableAsync
|
@EnableAsync
|
||||||
@Service
|
@Service
|
||||||
class NotificationService(
|
class MessageService(
|
||||||
private val applicationContext: ApplicationContext,
|
private val applicationContext: ApplicationContext,
|
||||||
private val templateService: MessageTemplateService,
|
private val templateService: MessageTemplateService,
|
||||||
private val userService: UserService
|
private val userService: UserService
|
||||||
@@ -29,8 +29,8 @@ class NotificationService(
|
|||||||
val enabled: Boolean
|
val enabled: Boolean
|
||||||
get() = providers.any { it.enabled }
|
get() = providers.any { it.enabled }
|
||||||
|
|
||||||
private val providers: List<AbstractNotificationProvider>
|
private val providers: List<AbstractMessageProvider>
|
||||||
get() = applicationContext.getBeansOfType(AbstractNotificationProvider::class.java).values.toList()
|
get() = applicationContext.getBeansOfType(AbstractMessageProvider::class.java).values.toList()
|
||||||
|
|
||||||
fun testCredentials(provider: String, credentials: Map<String, Any>): Boolean {
|
fun testCredentials(provider: String, credentials: Map<String, Any>): Boolean {
|
||||||
val notificationProvider = providers.find { it.providerKey == provider }
|
val notificationProvider = providers.find { it.providerKey == provider }
|
||||||
@@ -87,7 +87,7 @@ class NotificationService(
|
|||||||
log.info { "Sending password reset request notification" }
|
log.info { "Sending password reset request notification" }
|
||||||
|
|
||||||
val token = event.token
|
val token = event.token
|
||||||
val resetLink = event.baseUrl + "/reset-password?token=${token.token}"
|
val resetLink = event.baseUrl + "/reset-password?token=${token.secret}"
|
||||||
sendNotification(
|
sendNotification(
|
||||||
token.user.email,
|
token.user.email,
|
||||||
"[Gameyfin] Password Reset Request",
|
"[Gameyfin] Password Reset Request",
|
||||||
+4
-4
@@ -1,16 +1,16 @@
|
|||||||
package de.grimsi.gameyfin.notifications.providers
|
package de.grimsi.gameyfin.messages.providers
|
||||||
|
|
||||||
import de.grimsi.gameyfin.config.ConfigService
|
import de.grimsi.gameyfin.config.ConfigService
|
||||||
import de.grimsi.gameyfin.notifications.templates.TemplateType
|
import de.grimsi.gameyfin.messages.templates.TemplateType
|
||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
abstract class AbstractNotificationProvider(
|
abstract class AbstractMessageProvider(
|
||||||
val providerKey: String,
|
val providerKey: String,
|
||||||
val supportedTemplateType: TemplateType,
|
val supportedTemplateType: TemplateType,
|
||||||
protected val config: ConfigService
|
protected val config: ConfigService
|
||||||
) {
|
) {
|
||||||
protected companion object {
|
protected companion object {
|
||||||
const val BASE_KEY = "notifications.providers"
|
const val BASE_KEY = "messages.providers"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val configKey = String.format("%s.%s.enabled", BASE_KEY, providerKey)
|
private val configKey = String.format("%s.%s.enabled", BASE_KEY, providerKey)
|
||||||
+8
-8
@@ -1,8 +1,8 @@
|
|||||||
package de.grimsi.gameyfin.notifications.providers
|
package de.grimsi.gameyfin.messages.providers
|
||||||
|
|
||||||
import de.grimsi.gameyfin.config.ConfigProperties
|
import de.grimsi.gameyfin.config.ConfigProperties
|
||||||
import de.grimsi.gameyfin.config.ConfigService
|
import de.grimsi.gameyfin.config.ConfigService
|
||||||
import de.grimsi.gameyfin.notifications.templates.TemplateType
|
import de.grimsi.gameyfin.messages.templates.TemplateType
|
||||||
import jakarta.mail.Message
|
import jakarta.mail.Message
|
||||||
import jakarta.mail.MessagingException
|
import jakarta.mail.MessagingException
|
||||||
import jakarta.mail.Session
|
import jakarta.mail.Session
|
||||||
@@ -12,17 +12,17 @@ import org.springframework.stereotype.Service
|
|||||||
import java.util.*
|
import java.util.*
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class EmailNotificationProvider(
|
class EmailMessageProvider(
|
||||||
config: ConfigService
|
config: ConfigService
|
||||||
) : AbstractNotificationProvider("email", TemplateType.MJML, config) {
|
) : AbstractMessageProvider("email", TemplateType.MJML, config) {
|
||||||
|
|
||||||
private val storedCredentials: Properties
|
private val storedCredentials: Properties
|
||||||
get() {
|
get() {
|
||||||
val properties = Properties()
|
val properties = Properties()
|
||||||
properties["host"] = config.get(ConfigProperties.Notifications.Providers.Email.Host)
|
properties["host"] = config.get(ConfigProperties.Messages.Providers.Email.Host)
|
||||||
properties["port"] = config.get(ConfigProperties.Notifications.Providers.Email.Port)
|
properties["port"] = config.get(ConfigProperties.Messages.Providers.Email.Port)
|
||||||
properties["username"] = config.get(ConfigProperties.Notifications.Providers.Email.Username)
|
properties["username"] = config.get(ConfigProperties.Messages.Providers.Email.Username)
|
||||||
properties["password"] = config.get(ConfigProperties.Notifications.Providers.Email.Password)
|
properties["password"] = config.get(ConfigProperties.Messages.Providers.Email.Password)
|
||||||
return properties
|
return properties
|
||||||
}
|
}
|
||||||
|
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package de.grimsi.gameyfin.notifications.templates
|
package de.grimsi.gameyfin.messages.templates
|
||||||
|
|
||||||
data class MessageTemplateDto(
|
data class MessageTemplateDto(
|
||||||
val key: String,
|
val key: String,
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package de.grimsi.gameyfin.notifications.templates
|
package de.grimsi.gameyfin.messages.templates
|
||||||
|
|
||||||
import com.vaadin.hilla.Endpoint
|
import com.vaadin.hilla.Endpoint
|
||||||
import de.grimsi.gameyfin.core.Roles
|
import de.grimsi.gameyfin.core.Roles
|
||||||
+2
-2
@@ -1,4 +1,4 @@
|
|||||||
package de.grimsi.gameyfin.notifications.templates
|
package de.grimsi.gameyfin.messages.templates
|
||||||
|
|
||||||
import ch.digitalfondue.mjml4j.Mjml4j
|
import ch.digitalfondue.mjml4j.Mjml4j
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
@@ -14,7 +14,7 @@ class MessageTemplateService {
|
|||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private const val TEMPLATE_PATH = "templates"
|
private const val TEMPLATE_PATH = "templates"
|
||||||
private const val DEFAULT_TEMPLATE_PATH = "templates/notifications"
|
private const val DEFAULT_TEMPLATE_PATH = "templates/messages"
|
||||||
}
|
}
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
+8
-1
@@ -1,4 +1,4 @@
|
|||||||
package de.grimsi.gameyfin.notifications.templates
|
package de.grimsi.gameyfin.messages.templates
|
||||||
|
|
||||||
sealed class MessageTemplates(
|
sealed class MessageTemplates(
|
||||||
val key: String,
|
val key: String,
|
||||||
@@ -20,6 +20,13 @@ sealed class MessageTemplates(
|
|||||||
listOf("username")
|
listOf("username")
|
||||||
)
|
)
|
||||||
|
|
||||||
|
data object EmailConfirmation : MessageTemplates(
|
||||||
|
"email-confirmation",
|
||||||
|
"Email Confirmation",
|
||||||
|
"Template for the email confirmation message",
|
||||||
|
listOf("username", "confirmationLink")
|
||||||
|
)
|
||||||
|
|
||||||
data object PasswordResetRequest : MessageTemplates(
|
data object PasswordResetRequest : MessageTemplates(
|
||||||
"password-reset-request",
|
"password-reset-request",
|
||||||
"Password Reset Request",
|
"Password Reset Request",
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package de.grimsi.gameyfin.notifications.templates
|
package de.grimsi.gameyfin.messages.templates
|
||||||
|
|
||||||
abstract class MjmlTemplate {
|
abstract class MjmlTemplate {
|
||||||
companion object {
|
companion object {
|
||||||
+1
-1
@@ -1,4 +1,4 @@
|
|||||||
package de.grimsi.gameyfin.notifications.templates
|
package de.grimsi.gameyfin.messages.templates
|
||||||
|
|
||||||
enum class TemplateType(val extension: String) {
|
enum class TemplateType(val extension: String) {
|
||||||
MJML("mjml"), TEXT("txt")
|
MJML("mjml"), TEXT("txt")
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package de.grimsi.gameyfin.shared.token
|
||||||
|
|
||||||
|
import de.grimsi.gameyfin.core.security.EncryptionConverter
|
||||||
|
import de.grimsi.gameyfin.users.entities.User
|
||||||
|
import jakarta.persistence.Convert
|
||||||
|
import jakarta.persistence.Entity
|
||||||
|
import jakarta.persistence.FetchType
|
||||||
|
import jakarta.persistence.Id
|
||||||
|
import jakarta.persistence.OneToOne
|
||||||
|
import org.hibernate.annotations.CreationTimestamp
|
||||||
|
import org.hibernate.annotations.Type
|
||||||
|
import java.time.Instant
|
||||||
|
import java.util.UUID
|
||||||
|
import kotlin.time.toJavaDuration
|
||||||
|
|
||||||
|
@Entity
|
||||||
|
class Token<T : TokenType>(
|
||||||
|
@Id
|
||||||
|
@Convert(converter = EncryptionConverter::class)
|
||||||
|
val secret: String = UUID.randomUUID().toString(),
|
||||||
|
|
||||||
|
@Type(TokenTypeUserType::class)
|
||||||
|
val type: T,
|
||||||
|
|
||||||
|
@OneToOne(targetEntity = User::class, fetch = FetchType.EAGER)
|
||||||
|
val user: User,
|
||||||
|
|
||||||
|
@CreationTimestamp
|
||||||
|
val createdOn: Instant? = null
|
||||||
|
) {
|
||||||
|
val expired: Boolean
|
||||||
|
get() = createdOn?.plus(type.expiration.toJavaDuration())!!.isBefore(Instant.now())
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
package de.grimsi.gameyfin.shared.token
|
||||||
|
|
||||||
|
import de.grimsi.gameyfin.users.entities.User
|
||||||
|
import org.springframework.data.jpa.repository.JpaRepository
|
||||||
|
|
||||||
|
interface TokenRepository : JpaRepository<Token<*>, String> {
|
||||||
|
fun findBySecret(secret: String): Token<*>?
|
||||||
|
fun <T : TokenType> findByUserAndType(user: User, type: T): Token<T>?
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
package de.grimsi.gameyfin.shared.token
|
||||||
|
|
||||||
|
import de.grimsi.gameyfin.users.entities.User
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
|
|
||||||
|
abstract class TokenService<T : TokenType>(
|
||||||
|
private val type: T,
|
||||||
|
private val tokenRepository: TokenRepository
|
||||||
|
) {
|
||||||
|
|
||||||
|
private val log = KotlinLogging.logger {}
|
||||||
|
|
||||||
|
open fun generate(user: User): Token<T> {
|
||||||
|
val token = Token(
|
||||||
|
user = user,
|
||||||
|
type = type
|
||||||
|
)
|
||||||
|
|
||||||
|
tokenRepository.findByUserAndType(user, type)?.let {
|
||||||
|
log.warn { "Deleting existing '${it.type}' token for user '${user.username}'" }
|
||||||
|
delete(it)
|
||||||
|
}
|
||||||
|
|
||||||
|
return tokenRepository.save(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun get(secret: String, type: T): Token<T>? {
|
||||||
|
val token = tokenRepository.findBySecret(secret) ?: return null
|
||||||
|
|
||||||
|
return if (token.type == type) {
|
||||||
|
@Suppress("UNCHECKED_CAST")
|
||||||
|
token as Token<T>
|
||||||
|
} else {
|
||||||
|
log.error { "Token '$token' is not of type '$type'" }
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun delete(token: Token<T>) {
|
||||||
|
tokenRepository.delete(token)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun validate(secret: String): TokenValidationResult {
|
||||||
|
val token = tokenRepository.findBySecret(secret) ?: return TokenValidationResult.INVALID
|
||||||
|
return if (token.expired) TokenValidationResult.EXPIRED else TokenValidationResult.VALID
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
package de.grimsi.gameyfin.shared.token
|
||||||
|
|
||||||
|
import java.io.Serializable
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.minutes
|
||||||
|
|
||||||
|
sealed class TokenType(
|
||||||
|
val key: String,
|
||||||
|
val expiration: Duration
|
||||||
|
) : Serializable {
|
||||||
|
data object PasswordReset : TokenType("password-reset", 15.minutes)
|
||||||
|
data object EmailVerification : TokenType("email-verification", Duration.INFINITE)
|
||||||
|
data object Invitation : TokenType("invitation", Duration.INFINITE)
|
||||||
|
|
||||||
|
fun readResolve(): Any = when (this) {
|
||||||
|
PasswordReset -> PasswordReset
|
||||||
|
EmailVerification -> EmailVerification
|
||||||
|
Invitation -> Invitation
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
package de.grimsi.gameyfin.shared.token
|
||||||
|
|
||||||
|
import org.hibernate.engine.spi.SharedSessionContractImplementor
|
||||||
|
import org.hibernate.usertype.UserType
|
||||||
|
import java.io.Serializable
|
||||||
|
import java.sql.PreparedStatement
|
||||||
|
import java.sql.ResultSet
|
||||||
|
import java.sql.Types
|
||||||
|
|
||||||
|
class TokenTypeUserType : UserType<TokenType> {
|
||||||
|
|
||||||
|
override fun getSqlType(): Int = Types.VARCHAR
|
||||||
|
|
||||||
|
override fun returnedClass(): Class<TokenType> = TokenType::class.java
|
||||||
|
|
||||||
|
override fun equals(x: TokenType, y: TokenType): Boolean = x.key == y.key
|
||||||
|
|
||||||
|
override fun hashCode(x: TokenType): Int = x.key.hashCode()
|
||||||
|
|
||||||
|
override fun nullSafeGet(
|
||||||
|
rs: ResultSet,
|
||||||
|
position: Int,
|
||||||
|
session: SharedSessionContractImplementor,
|
||||||
|
owner: Any?
|
||||||
|
): TokenType? {
|
||||||
|
val key = rs.getString(position) ?: return null
|
||||||
|
return when (key) {
|
||||||
|
TokenType.PasswordReset.key -> TokenType.PasswordReset
|
||||||
|
TokenType.EmailVerification.key -> TokenType.EmailVerification
|
||||||
|
TokenType.Invitation.key -> TokenType.Invitation
|
||||||
|
else -> throw IllegalArgumentException("Unknown TokenType key: $key")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun nullSafeSet(
|
||||||
|
st: PreparedStatement,
|
||||||
|
value: TokenType?,
|
||||||
|
index: Int,
|
||||||
|
session: SharedSessionContractImplementor
|
||||||
|
) {
|
||||||
|
if (value == null) {
|
||||||
|
st.setNull(index, Types.VARCHAR)
|
||||||
|
} else {
|
||||||
|
st.setString(index, value.key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun deepCopy(value: TokenType): TokenType = value
|
||||||
|
|
||||||
|
override fun isMutable(): Boolean = false
|
||||||
|
|
||||||
|
override fun disassemble(value: TokenType): Serializable = value.key
|
||||||
|
|
||||||
|
override fun assemble(cached: Serializable, owner: Any?): TokenType = cached as TokenType
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
package de.grimsi.gameyfin.shared.token
|
||||||
|
|
||||||
|
enum class TokenValidationResult() {
|
||||||
|
VALID, INVALID, EXPIRED
|
||||||
|
}
|
||||||
@@ -3,13 +3,14 @@ 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.core.Roles
|
||||||
import de.grimsi.gameyfin.users.dto.PasswordResetResult
|
import de.grimsi.gameyfin.shared.token.TokenValidationResult
|
||||||
import jakarta.annotation.security.RolesAllowed
|
import jakarta.annotation.security.RolesAllowed
|
||||||
|
|
||||||
@Endpoint
|
@Endpoint
|
||||||
@AnonymousAllowed
|
@AnonymousAllowed
|
||||||
class PasswordResetEndpoint(
|
class PasswordResetEndpoint(
|
||||||
private val passwordResetService: PasswordResetService
|
private val passwordResetService: PasswordResetService,
|
||||||
|
private val userService: UserService
|
||||||
) {
|
) {
|
||||||
|
|
||||||
fun requestPasswordReset(email: String) {
|
fun requestPasswordReset(email: String) {
|
||||||
@@ -20,10 +21,10 @@ class PasswordResetEndpoint(
|
|||||||
|
|
||||||
@RolesAllowed(Roles.Names.ADMIN)
|
@RolesAllowed(Roles.Names.ADMIN)
|
||||||
fun createPasswordResetTokenForUser(username: String): String {
|
fun createPasswordResetTokenForUser(username: String): String {
|
||||||
return passwordResetService.createPasswordResetToken(username)
|
return passwordResetService.generate(username).secret
|
||||||
}
|
}
|
||||||
|
|
||||||
fun resetPassword(token: String, newPassword: String): PasswordResetResult {
|
fun resetPassword(secret: String, newPassword: String): TokenValidationResult {
|
||||||
return passwordResetService.resetPassword(token, newPassword)
|
return passwordResetService.resetPassword(secret, newPassword)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -2,43 +2,63 @@ 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.messages.MessageService
|
||||||
import de.grimsi.gameyfin.users.dto.PasswordResetResult
|
import de.grimsi.gameyfin.shared.token.Token
|
||||||
import de.grimsi.gameyfin.users.entities.PasswordResetToken
|
import de.grimsi.gameyfin.shared.token.TokenRepository
|
||||||
|
import de.grimsi.gameyfin.shared.token.TokenService
|
||||||
|
import de.grimsi.gameyfin.shared.token.TokenType.PasswordReset
|
||||||
|
import de.grimsi.gameyfin.shared.token.TokenValidationResult
|
||||||
import de.grimsi.gameyfin.users.entities.User
|
import de.grimsi.gameyfin.users.entities.User
|
||||||
import de.grimsi.gameyfin.users.persistence.PasswordResetTokenRepository
|
|
||||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import org.springframework.context.ApplicationEventPublisher
|
import org.springframework.context.ApplicationEventPublisher
|
||||||
import org.springframework.stereotype.Service
|
import org.springframework.stereotype.Service
|
||||||
import java.security.SecureRandom
|
import java.security.SecureRandom
|
||||||
import java.time.Instant
|
|
||||||
import java.util.*
|
|
||||||
import kotlin.time.Duration.Companion.hours
|
|
||||||
import kotlin.time.toJavaDuration
|
|
||||||
|
|
||||||
@Service
|
@Service
|
||||||
class PasswordResetService(
|
class PasswordResetService(
|
||||||
|
tokenRepository: TokenRepository,
|
||||||
private val userService: UserService,
|
private val userService: UserService,
|
||||||
|
private val messageService: MessageService,
|
||||||
private val sessionService: SessionService,
|
private val sessionService: SessionService,
|
||||||
private val notificationService: NotificationService,
|
private val eventPublisher: ApplicationEventPublisher
|
||||||
private val eventPublisher: ApplicationEventPublisher,
|
) : TokenService<PasswordReset>(PasswordReset, tokenRepository) {
|
||||||
private val passwordResetTokenRepository: PasswordResetTokenRepository
|
|
||||||
) {
|
|
||||||
|
|
||||||
private companion object {
|
|
||||||
val TOKEN_EXPIRATION = 24.hours
|
|
||||||
}
|
|
||||||
|
|
||||||
private val log = KotlinLogging.logger {}
|
private val log = KotlinLogging.logger {}
|
||||||
|
|
||||||
private val secureRandom = SecureRandom()
|
private val secureRandom = SecureRandom()
|
||||||
|
|
||||||
private val PasswordResetToken.isExpired: Boolean
|
|
||||||
get() = createdOn?.plus(TOKEN_EXPIRATION.toJavaDuration())!!.isBefore(Instant.now())
|
|
||||||
|
|
||||||
private val baseUrl: String
|
private val baseUrl: String
|
||||||
get() = Utils.getBaseUrl()
|
get() = Utils.getBaseUrl()
|
||||||
|
|
||||||
|
override fun generate(user: User): Token<PasswordReset> {
|
||||||
|
if (user.oidcProviderId != null) {
|
||||||
|
throw IllegalStateException("Cannot create password reset token for user '${user.username}' because user is managed externally")
|
||||||
|
}
|
||||||
|
|
||||||
|
return super.generate(user)
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 generate(username: String): Token<PasswordReset> {
|
||||||
|
if (messageService.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 generate(user)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Users can request a password reset when the following conditions are met:
|
* Users can request a password reset when the following conditions are met:
|
||||||
* - The user has confirmed their email address
|
* - The user has confirmed their email address
|
||||||
@@ -67,66 +87,26 @@ class PasswordResetService(
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val token = createPasswordResetToken(user)
|
val token = generate(user)
|
||||||
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, baseUrl))
|
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 resetPassword(token: String, newPassword: String): TokenValidationResult {
|
||||||
if (user.oidcProviderId != null) {
|
val passwordResetToken = get(token, PasswordReset)
|
||||||
throw IllegalStateException("Cannot create password reset token for user '${user.username}' because user is managed externally")
|
?: return TokenValidationResult.INVALID
|
||||||
}
|
|
||||||
|
|
||||||
val token = PasswordResetToken(
|
if (passwordResetToken.expired) {
|
||||||
user = user,
|
return TokenValidationResult.EXPIRED
|
||||||
token = UUID.randomUUID().toString()
|
|
||||||
)
|
|
||||||
|
|
||||||
passwordResetTokenRepository.findByUser(user)?.let {
|
|
||||||
passwordResetTokenRepository.delete(it)
|
|
||||||
}
|
|
||||||
|
|
||||||
return passwordResetTokenRepository.save(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* 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)
|
|
||||||
?: return PasswordResetResult.INVALID_TOKEN
|
|
||||||
|
|
||||||
if (passwordResetToken.isExpired) {
|
|
||||||
return PasswordResetResult.EXPIRED_TOKEN
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val user = passwordResetToken.user
|
val user = passwordResetToken.user
|
||||||
|
|
||||||
userService.updatePassword(user, newPassword)
|
userService.updatePassword(user, newPassword)
|
||||||
passwordResetTokenRepository.delete(passwordResetToken)
|
delete(passwordResetToken)
|
||||||
sessionService.logoutAllSessions(user)
|
sessionService.logoutAllSessions(user)
|
||||||
return PasswordResetResult.SUCCESS
|
return TokenValidationResult.VALID
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,7 @@ import de.grimsi.gameyfin.users.entities.Role
|
|||||||
import de.grimsi.gameyfin.users.entities.User
|
import de.grimsi.gameyfin.users.entities.User
|
||||||
import de.grimsi.gameyfin.users.persistence.AvatarContentStore
|
import de.grimsi.gameyfin.users.persistence.AvatarContentStore
|
||||||
import de.grimsi.gameyfin.users.persistence.UserRepository
|
import de.grimsi.gameyfin.users.persistence.UserRepository
|
||||||
|
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||||
import jakarta.transaction.Transactional
|
import jakarta.transaction.Transactional
|
||||||
import org.springframework.security.core.Authentication
|
import org.springframework.security.core.Authentication
|
||||||
import org.springframework.security.core.GrantedAuthority
|
import org.springframework.security.core.GrantedAuthority
|
||||||
@@ -26,12 +27,14 @@ import java.io.InputStream
|
|||||||
@Transactional
|
@Transactional
|
||||||
class UserService(
|
class UserService(
|
||||||
private val userRepository: UserRepository,
|
private val userRepository: UserRepository,
|
||||||
|
private val avatarStore: AvatarContentStore,
|
||||||
private val passwordEncoder: PasswordEncoder,
|
private val passwordEncoder: PasswordEncoder,
|
||||||
private val roleService: RoleService,
|
private val roleService: RoleService,
|
||||||
private val sessionService: SessionService,
|
private val sessionService: SessionService
|
||||||
private val avatarStore: AvatarContentStore
|
|
||||||
) : UserDetailsService {
|
) : UserDetailsService {
|
||||||
|
|
||||||
|
private val log = KotlinLogging.logger {}
|
||||||
|
|
||||||
override fun loadUserByUsername(username: String): UserDetails {
|
override fun loadUserByUsername(username: String): UserDetails {
|
||||||
val user = userByUsername(username)
|
val user = userByUsername(username)
|
||||||
|
|
||||||
@@ -125,15 +128,18 @@ class UserService(
|
|||||||
val user = userByUsername(username)
|
val user = userByUsername(username)
|
||||||
|
|
||||||
updates.username?.let { user.username = it }
|
updates.username?.let { user.username = it }
|
||||||
updates.password?.let { user.password = passwordEncoder.encode(it) }
|
|
||||||
updates.email?.let { user.email = it }
|
|
||||||
|
|
||||||
userRepository.save(user)
|
updates.password?.let {
|
||||||
|
user.password = passwordEncoder.encode(it)
|
||||||
// If user changes password, all sessions should be invalidated
|
|
||||||
if (updates.password != null) {
|
|
||||||
sessionService.logoutAllSessions()
|
sessionService.logoutAllSessions()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updates.email?.let {
|
||||||
|
user.email = it
|
||||||
|
user.emailConfirmed = false
|
||||||
|
}
|
||||||
|
|
||||||
|
userRepository.save(user)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun updatePassword(user: User, newPassword: String) {
|
fun updatePassword(user: User, newPassword: String) {
|
||||||
|
|||||||
@@ -1,5 +0,0 @@
|
|||||||
package de.grimsi.gameyfin.users.dto
|
|
||||||
|
|
||||||
enum class PasswordResetResult() {
|
|
||||||
SUCCESS, INVALID_TOKEN, EXPIRED_TOKEN
|
|
||||||
}
|
|
||||||
@@ -1,19 +0,0 @@
|
|||||||
package de.grimsi.gameyfin.users.entities
|
|
||||||
|
|
||||||
import de.grimsi.gameyfin.core.security.EncryptionConverter
|
|
||||||
import jakarta.persistence.*
|
|
||||||
import org.hibernate.annotations.CreationTimestamp
|
|
||||||
import java.time.Instant
|
|
||||||
|
|
||||||
@Entity
|
|
||||||
class PasswordResetToken(
|
|
||||||
@Id
|
|
||||||
@Convert(converter = EncryptionConverter::class)
|
|
||||||
val token: String,
|
|
||||||
|
|
||||||
@OneToOne(targetEntity = User::class, fetch = FetchType.EAGER)
|
|
||||||
val user: User,
|
|
||||||
|
|
||||||
@CreationTimestamp
|
|
||||||
val createdOn: Instant? = null
|
|
||||||
)
|
|
||||||
@@ -2,5 +2,7 @@ package de.grimsi.gameyfin.users.persistence
|
|||||||
|
|
||||||
import de.grimsi.gameyfin.users.entities.Avatar
|
import de.grimsi.gameyfin.users.entities.Avatar
|
||||||
import org.springframework.content.commons.store.ContentStore
|
import org.springframework.content.commons.store.ContentStore
|
||||||
|
import org.springframework.stereotype.Repository
|
||||||
|
|
||||||
|
@Repository
|
||||||
interface AvatarContentStore : ContentStore<Avatar, String>
|
interface AvatarContentStore : ContentStore<Avatar, String>
|
||||||
@@ -1,10 +0,0 @@
|
|||||||
package de.grimsi.gameyfin.users.persistence
|
|
||||||
|
|
||||||
import de.grimsi.gameyfin.users.entities.PasswordResetToken
|
|
||||||
import de.grimsi.gameyfin.users.entities.User
|
|
||||||
import org.springframework.data.jpa.repository.JpaRepository
|
|
||||||
|
|
||||||
interface PasswordResetTokenRepository : JpaRepository<PasswordResetToken, String> {
|
|
||||||
fun findByToken(token: String): PasswordResetToken?
|
|
||||||
fun findByUser(user: User): PasswordResetToken?
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,25 @@
|
|||||||
|
<mjml>
|
||||||
|
<mj-head>
|
||||||
|
<mj-title>Please confirm your email address</mj-title>
|
||||||
|
<mj-attributes>
|
||||||
|
<mj-all font-family="Arial, sans-serif"/>
|
||||||
|
<mj-text font-size="16px"/>
|
||||||
|
</mj-attributes>
|
||||||
|
</mj-head>
|
||||||
|
<mj-body>
|
||||||
|
<mj-section>
|
||||||
|
<mj-column>
|
||||||
|
<mj-image width="128px" src="{logo}"/>
|
||||||
|
<mj-image height="2px" padding-bottom="20px" src="{gradient}"/>
|
||||||
|
|
||||||
|
<mj-text font-size="20px" font-family="helvetica">Hey {username},
|
||||||
|
<br/>
|
||||||
|
<br/>
|
||||||
|
</mj-text>
|
||||||
|
<mj-text>please confirm your email address using the following link: {confirmationLink}
|
||||||
|
</mj-text>
|
||||||
|
|
||||||
|
</mj-column>
|
||||||
|
</mj-section>
|
||||||
|
</mj-body>
|
||||||
|
</mjml>
|
||||||
Reference in New Issue
Block a user