mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +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 Section from "Frontend/components/general/Section";
|
||||
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 {PaperPlaneRight, Pencil} from "@phosphor-icons/react";
|
||||
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
|
||||
import SendTestNotificationModal from "Frontend/components/administration/notifications/SendTestNotificationModal";
|
||||
import EditTemplateModal from "Frontend/components/administration/notifications/EditTemplateModel";
|
||||
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto";
|
||||
import SendTestNotificationModal from "Frontend/components/administration/messages/SendTestNotificationModal";
|
||||
import EditTemplateModal from "Frontend/components/administration/messages/EditTemplateModel";
|
||||
|
||||
function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
||||
function MessageManagementLayout({getConfig, getConfigs, formik}: any) {
|
||||
|
||||
const editorModal = useDisclosure();
|
||||
const testNotificationModal = useDisclosure();
|
||||
@@ -25,13 +25,13 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
||||
|
||||
async function verifyCredentials(provider: string) {
|
||||
const credentials: Record<string, any> = {
|
||||
host: formik.values.notifications.providers.email.host,
|
||||
port: formik.values.notifications.providers.email.port,
|
||||
username: formik.values.notifications.providers.email.username,
|
||||
password: formik.values.notifications.providers.email.password
|
||||
host: formik.values.messages.providers.email.host,
|
||||
port: formik.values.messages.providers.email.port,
|
||||
username: formik.values.messages.providers.email.username,
|
||||
password: formik.values.messages.providers.email.password
|
||||
}
|
||||
|
||||
const areCredentialsValid = await NotificationEndpoint.verifyCredentials(provider, credentials);
|
||||
const areCredentialsValid = await MessageEndpoint.verifyCredentials(provider, credentials);
|
||||
|
||||
if (areCredentialsValid) {
|
||||
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-col flex-1">
|
||||
<Section title="E-Mail"/>
|
||||
<ConfigFormField configElement={getConfig("notifications.providers.email.enabled")}/>
|
||||
<ConfigFormField configElement={getConfig("notifications.providers.email.host")}
|
||||
isDisabled={!formik.values.notifications.providers.email.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("notifications.providers.email.port")}
|
||||
isDisabled={!formik.values.notifications.providers.email.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("notifications.providers.email.username")}
|
||||
isDisabled={!formik.values.notifications.providers.email.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("notifications.providers.email.password")}
|
||||
<ConfigFormField configElement={getConfig("messages.providers.email.enabled")}/>
|
||||
<ConfigFormField configElement={getConfig("messages.providers.email.host")}
|
||||
isDisabled={!formik.values.messages.providers.email.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("messages.providers.email.port")}
|
||||
isDisabled={!formik.values.messages.providers.email.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("messages.providers.email.username")}
|
||||
isDisabled={!formik.values.messages.providers.email.enabled}/>
|
||||
<ConfigFormField configElement={getConfig("messages.providers.email.password")}
|
||||
type="password"
|
||||
isDisabled={!formik.values.notifications.providers.email.enabled}/>
|
||||
isDisabled={!formik.values.messages.providers.email.enabled}/>
|
||||
<Button onPress={() => verifyCredentials("email")}
|
||||
isDisabled={!(
|
||||
formik.values.notifications.providers.email.enabled &&
|
||||
formik.values.notifications.providers.email.host &&
|
||||
formik.values.notifications.providers.email.port &&
|
||||
formik.values.notifications.providers.email.username)}>Test</Button>
|
||||
formik.values.messages.providers.email.enabled &&
|
||||
formik.values.messages.providers.email.host &&
|
||||
formik.values.messages.providers.email.port &&
|
||||
formik.values.messages.providers.email.username)}>Test</Button>
|
||||
</div>
|
||||
<div className="flex flex-col flex-1">
|
||||
<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";
|
||||
import {toast} from "sonner";
|
||||
import {MessageTemplateEndpoint} from "Frontend/generated/endpoints";
|
||||
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
|
||||
import TemplateType from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/TemplateType";
|
||||
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto";
|
||||
import TemplateType from "Frontend/generated/de/grimsi/gameyfin/messages/templates/TemplateType";
|
||||
|
||||
interface EditTemplateModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -41,6 +41,12 @@ export default function EditTemplateModal({isOpen, onOpenChange, selectedTemplat
|
||||
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 (
|
||||
<Modal isOpen={isOpen} onOpenChange={onOpenChange} size="5xl">
|
||||
<ModalContent>
|
||||
@@ -98,15 +104,17 @@ export default function EditTemplateModal({isOpen, onOpenChange, selectedTemplat
|
||||
</ModalBody>
|
||||
<ModalFooter>
|
||||
<Button color="danger" variant="light" onPress={onClose}>
|
||||
Close
|
||||
Cancel
|
||||
</Button>
|
||||
<Button color="primary" onPress={async () => {
|
||||
if (selectedTemplate) {
|
||||
await saveTemplate(selectedTemplate);
|
||||
toast.success("Template saved");
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
<Button color="primary"
|
||||
isDisabled={!templateContainsAllRequiredPlaceholders()}
|
||||
onPress={async () => {
|
||||
if (selectedTemplate) {
|
||||
await saveTemplate(selectedTemplate);
|
||||
toast.success("Template saved");
|
||||
onClose();
|
||||
}
|
||||
}}>
|
||||
Save
|
||||
</Button>
|
||||
</ModalFooter>
|
||||
+3
-3
@@ -3,9 +3,9 @@ import {Form, Formik} from "formik";
|
||||
import {Button, Modal, ModalBody, ModalContent, ModalFooter, ModalHeader} from "@nextui-org/react";
|
||||
import {toast} from "sonner";
|
||||
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 MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
|
||||
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/messages/templates/MessageTemplateDto";
|
||||
|
||||
interface SendTestNotificationModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -35,7 +35,7 @@ export default function SendTestNotificationModal({
|
||||
<Formik
|
||||
initialValues={{}}
|
||||
onSubmit={async (values) => {
|
||||
await NotificationEndpoint.sendTestNotification(selectedTemplate?.key, values);
|
||||
await MessageEndpoint.sendTestNotification(selectedTemplate?.key, values);
|
||||
toast.success("Test notification to you has been sent");
|
||||
onClose();
|
||||
}}
|
||||
@@ -12,7 +12,7 @@ import ProfileManagement from "Frontend/components/administration/ProfileManagem
|
||||
import {SsoManagement} from "Frontend/components/administration/SsoManagement";
|
||||
import {AdministrationView} from "Frontend/views/AdministrationView";
|
||||
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 PasswordResetView from "Frontend/views/PasswordResetView";
|
||||
|
||||
@@ -43,7 +43,7 @@ export const routes = protectRoutes([
|
||||
{path: 'libraries', element: <LibraryManagement/>},
|
||||
{path: 'users', element: <UserManagement/>},
|
||||
{path: 'sso', element: <SsoManagement/>},
|
||||
{path: 'notifications', element: <NotificationManagement/>},
|
||||
{path: 'messages', element: <MessageManagement/>},
|
||||
{path: 'logs', element: <LogManagement/>}
|
||||
]
|
||||
}
|
||||
|
||||
@@ -18,8 +18,8 @@ const menuItems: MenuItem[] = [
|
||||
icon: <LockKey/>
|
||||
},
|
||||
{
|
||||
title: "Notifications",
|
||||
url: "notifications",
|
||||
title: "Messages",
|
||||
url: "messages",
|
||||
icon: <Envelope/>
|
||||
},
|
||||
{
|
||||
|
||||
@@ -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 {NotificationEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||
import {MessageEndpoint, PasswordResetEndpoint} from "Frontend/generated/endpoints";
|
||||
import {toast} from "sonner";
|
||||
|
||||
export default function LoginView() {
|
||||
@@ -35,7 +35,7 @@ export default function LoginView() {
|
||||
const navigate = useNavigate();
|
||||
|
||||
useEffect(() => {
|
||||
NotificationEndpoint.isEnabled().then(setCanResetPassword);
|
||||
MessageEndpoint.isEnabled().then(setCanResetPassword);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
|
||||
@@ -7,7 +7,7 @@ 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";
|
||||
import TokenValidationResult from "Frontend/generated/de/grimsi/gameyfin/shared/token/TokenValidationResult";
|
||||
|
||||
export default function PasswordResetView() {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
@@ -21,17 +21,17 @@ export default function PasswordResetView() {
|
||||
|
||||
async function resetPassword(values: any) {
|
||||
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) {
|
||||
case PasswordResetResult.SUCCESS:
|
||||
case TokenValidationResult.VALID:
|
||||
toast.success("Password reset successfully");
|
||||
navigate("/", {replace: true});
|
||||
break;
|
||||
case PasswordResetResult.EXPIRED_TOKEN:
|
||||
case TokenValidationResult.EXPIRED:
|
||||
toast.error("Token is expired");
|
||||
break;
|
||||
case PasswordResetResult.INVALID_TOKEN:
|
||||
case TokenValidationResult.INVALID:
|
||||
default:
|
||||
toast.error("Token is invalid");
|
||||
break
|
||||
|
||||
@@ -141,39 +141,39 @@ sealed class ConfigProperties<T : Serializable>(
|
||||
}
|
||||
}
|
||||
|
||||
/** Notifications */
|
||||
sealed class Notifications {
|
||||
/** Messages */
|
||||
sealed class Messages {
|
||||
sealed class Providers {
|
||||
sealed class Email {
|
||||
data object Enabled : ConfigProperties<Boolean>(
|
||||
Boolean::class,
|
||||
"notifications.providers.email.enabled",
|
||||
"messages.providers.email.enabled",
|
||||
"Enable E-Mail notifications",
|
||||
false
|
||||
)
|
||||
|
||||
data object Host : ConfigProperties<String>(
|
||||
String::class,
|
||||
"notifications.providers.email.host",
|
||||
"messages.providers.email.host",
|
||||
"URL of the email server"
|
||||
)
|
||||
|
||||
data object Port : ConfigProperties<Int>(
|
||||
Int::class,
|
||||
"notifications.providers.email.port",
|
||||
"messages.providers.email.port",
|
||||
"Port of the email server",
|
||||
587
|
||||
)
|
||||
|
||||
data object Username : ConfigProperties<String>(
|
||||
String::class,
|
||||
"notifications.providers.email.username",
|
||||
"messages.providers.email.username",
|
||||
"Username for the email account"
|
||||
)
|
||||
|
||||
data object Password : ConfigProperties<String>(
|
||||
String::class,
|
||||
"notifications.providers.email.password",
|
||||
"messages.providers.email.password",
|
||||
"Password for the email account"
|
||||
)
|
||||
}
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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
|
||||
|
||||
class UserInvitationEvent(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)
|
||||
|
||||
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.hilla.Endpoint
|
||||
@@ -7,20 +7,20 @@ import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@Endpoint
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
class NotificationEndpoint(
|
||||
private val notificationService: NotificationService
|
||||
class MessageEndpoint(
|
||||
private val messageService: MessageService
|
||||
) {
|
||||
|
||||
@AnonymousAllowed
|
||||
fun isEnabled(): Boolean {
|
||||
return notificationService.enabled
|
||||
return messageService.enabled
|
||||
}
|
||||
|
||||
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 {
|
||||
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.notifications.providers.AbstractNotificationProvider
|
||||
import de.grimsi.gameyfin.notifications.templates.MessageTemplateService
|
||||
import de.grimsi.gameyfin.notifications.templates.MessageTemplates
|
||||
import de.grimsi.gameyfin.messages.providers.AbstractMessageProvider
|
||||
import de.grimsi.gameyfin.messages.templates.MessageTemplateService
|
||||
import de.grimsi.gameyfin.messages.templates.MessageTemplates
|
||||
import de.grimsi.gameyfin.users.UserService
|
||||
import io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
@@ -18,7 +18,7 @@ import java.util.*
|
||||
|
||||
@EnableAsync
|
||||
@Service
|
||||
class NotificationService(
|
||||
class MessageService(
|
||||
private val applicationContext: ApplicationContext,
|
||||
private val templateService: MessageTemplateService,
|
||||
private val userService: UserService
|
||||
@@ -29,8 +29,8 @@ class NotificationService(
|
||||
val enabled: Boolean
|
||||
get() = providers.any { it.enabled }
|
||||
|
||||
private val providers: List<AbstractNotificationProvider>
|
||||
get() = applicationContext.getBeansOfType(AbstractNotificationProvider::class.java).values.toList()
|
||||
private val providers: List<AbstractMessageProvider>
|
||||
get() = applicationContext.getBeansOfType(AbstractMessageProvider::class.java).values.toList()
|
||||
|
||||
fun testCredentials(provider: String, credentials: Map<String, Any>): Boolean {
|
||||
val notificationProvider = providers.find { it.providerKey == provider }
|
||||
@@ -87,7 +87,7 @@ class NotificationService(
|
||||
log.info { "Sending password reset request notification" }
|
||||
|
||||
val token = event.token
|
||||
val resetLink = event.baseUrl + "/reset-password?token=${token.token}"
|
||||
val resetLink = event.baseUrl + "/reset-password?token=${token.secret}"
|
||||
sendNotification(
|
||||
token.user.email,
|
||||
"[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.notifications.templates.TemplateType
|
||||
import de.grimsi.gameyfin.messages.templates.TemplateType
|
||||
import java.util.*
|
||||
|
||||
abstract class AbstractNotificationProvider(
|
||||
abstract class AbstractMessageProvider(
|
||||
val providerKey: String,
|
||||
val supportedTemplateType: TemplateType,
|
||||
protected val config: ConfigService
|
||||
) {
|
||||
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)
|
||||
+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.ConfigService
|
||||
import de.grimsi.gameyfin.notifications.templates.TemplateType
|
||||
import de.grimsi.gameyfin.messages.templates.TemplateType
|
||||
import jakarta.mail.Message
|
||||
import jakarta.mail.MessagingException
|
||||
import jakarta.mail.Session
|
||||
@@ -12,17 +12,17 @@ import org.springframework.stereotype.Service
|
||||
import java.util.*
|
||||
|
||||
@Service
|
||||
class EmailNotificationProvider(
|
||||
class EmailMessageProvider(
|
||||
config: ConfigService
|
||||
) : AbstractNotificationProvider("email", TemplateType.MJML, config) {
|
||||
) : AbstractMessageProvider("email", TemplateType.MJML, config) {
|
||||
|
||||
private val storedCredentials: Properties
|
||||
get() {
|
||||
val properties = Properties()
|
||||
properties["host"] = config.get(ConfigProperties.Notifications.Providers.Email.Host)
|
||||
properties["port"] = config.get(ConfigProperties.Notifications.Providers.Email.Port)
|
||||
properties["username"] = config.get(ConfigProperties.Notifications.Providers.Email.Username)
|
||||
properties["password"] = config.get(ConfigProperties.Notifications.Providers.Email.Password)
|
||||
properties["host"] = config.get(ConfigProperties.Messages.Providers.Email.Host)
|
||||
properties["port"] = config.get(ConfigProperties.Messages.Providers.Email.Port)
|
||||
properties["username"] = config.get(ConfigProperties.Messages.Providers.Email.Username)
|
||||
properties["password"] = config.get(ConfigProperties.Messages.Providers.Email.Password)
|
||||
return properties
|
||||
}
|
||||
|
||||
+1
-1
@@ -1,4 +1,4 @@
|
||||
package de.grimsi.gameyfin.notifications.templates
|
||||
package de.grimsi.gameyfin.messages.templates
|
||||
|
||||
data class MessageTemplateDto(
|
||||
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 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 io.github.oshai.kotlinlogging.KotlinLogging
|
||||
@@ -14,7 +14,7 @@ class MessageTemplateService {
|
||||
|
||||
companion object {
|
||||
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 {}
|
||||
+8
-1
@@ -1,4 +1,4 @@
|
||||
package de.grimsi.gameyfin.notifications.templates
|
||||
package de.grimsi.gameyfin.messages.templates
|
||||
|
||||
sealed class MessageTemplates(
|
||||
val key: String,
|
||||
@@ -20,6 +20,13 @@ sealed class MessageTemplates(
|
||||
listOf("username")
|
||||
)
|
||||
|
||||
data object EmailConfirmation : MessageTemplates(
|
||||
"email-confirmation",
|
||||
"Email Confirmation",
|
||||
"Template for the email confirmation message",
|
||||
listOf("username", "confirmationLink")
|
||||
)
|
||||
|
||||
data object PasswordResetRequest : MessageTemplates(
|
||||
"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 {
|
||||
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) {
|
||||
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.hilla.Endpoint
|
||||
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
|
||||
|
||||
@Endpoint
|
||||
@AnonymousAllowed
|
||||
class PasswordResetEndpoint(
|
||||
private val passwordResetService: PasswordResetService
|
||||
private val passwordResetService: PasswordResetService,
|
||||
private val userService: UserService
|
||||
) {
|
||||
|
||||
fun requestPasswordReset(email: String) {
|
||||
@@ -20,10 +21,10 @@ class PasswordResetEndpoint(
|
||||
|
||||
@RolesAllowed(Roles.Names.ADMIN)
|
||||
fun createPasswordResetTokenForUser(username: String): String {
|
||||
return passwordResetService.createPasswordResetToken(username)
|
||||
return passwordResetService.generate(username).secret
|
||||
}
|
||||
|
||||
fun resetPassword(token: String, newPassword: String): PasswordResetResult {
|
||||
return passwordResetService.resetPassword(token, newPassword)
|
||||
fun resetPassword(secret: String, newPassword: String): TokenValidationResult {
|
||||
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.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.messages.MessageService
|
||||
import de.grimsi.gameyfin.shared.token.Token
|
||||
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.persistence.PasswordResetTokenRepository
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.context.ApplicationEventPublisher
|
||||
import org.springframework.stereotype.Service
|
||||
import java.security.SecureRandom
|
||||
import java.time.Instant
|
||||
import java.util.*
|
||||
import kotlin.time.Duration.Companion.hours
|
||||
import kotlin.time.toJavaDuration
|
||||
|
||||
@Service
|
||||
class PasswordResetService(
|
||||
tokenRepository: TokenRepository,
|
||||
private val userService: UserService,
|
||||
private val messageService: MessageService,
|
||||
private val sessionService: SessionService,
|
||||
private val notificationService: NotificationService,
|
||||
private val eventPublisher: ApplicationEventPublisher,
|
||||
private val passwordResetTokenRepository: PasswordResetTokenRepository
|
||||
) {
|
||||
|
||||
private companion object {
|
||||
val TOKEN_EXPIRATION = 24.hours
|
||||
}
|
||||
private val eventPublisher: ApplicationEventPublisher
|
||||
) : TokenService<PasswordReset>(PasswordReset, tokenRepository) {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
private val secureRandom = SecureRandom()
|
||||
|
||||
private val PasswordResetToken.isExpired: Boolean
|
||||
get() = createdOn?.plus(TOKEN_EXPIRATION.toJavaDuration())!!.isBefore(Instant.now())
|
||||
|
||||
private val baseUrl: String
|
||||
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:
|
||||
* - The user has confirmed their email address
|
||||
@@ -67,66 +87,26 @@ class PasswordResetService(
|
||||
return
|
||||
}
|
||||
|
||||
val token = createPasswordResetToken(user)
|
||||
val token = generate(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")
|
||||
}
|
||||
fun resetPassword(token: String, newPassword: String): TokenValidationResult {
|
||||
val passwordResetToken = get(token, PasswordReset)
|
||||
?: return TokenValidationResult.INVALID
|
||||
|
||||
val token = PasswordResetToken(
|
||||
user = user,
|
||||
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
|
||||
if (passwordResetToken.expired) {
|
||||
return TokenValidationResult.EXPIRED
|
||||
}
|
||||
|
||||
val user = passwordResetToken.user
|
||||
|
||||
userService.updatePassword(user, newPassword)
|
||||
passwordResetTokenRepository.delete(passwordResetToken)
|
||||
delete(passwordResetToken)
|
||||
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.persistence.AvatarContentStore
|
||||
import de.grimsi.gameyfin.users.persistence.UserRepository
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import jakarta.transaction.Transactional
|
||||
import org.springframework.security.core.Authentication
|
||||
import org.springframework.security.core.GrantedAuthority
|
||||
@@ -26,12 +27,14 @@ import java.io.InputStream
|
||||
@Transactional
|
||||
class UserService(
|
||||
private val userRepository: UserRepository,
|
||||
private val avatarStore: AvatarContentStore,
|
||||
private val passwordEncoder: PasswordEncoder,
|
||||
private val roleService: RoleService,
|
||||
private val sessionService: SessionService,
|
||||
private val avatarStore: AvatarContentStore
|
||||
private val sessionService: SessionService
|
||||
) : UserDetailsService {
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
override fun loadUserByUsername(username: String): UserDetails {
|
||||
val user = userByUsername(username)
|
||||
|
||||
@@ -125,15 +128,18 @@ class UserService(
|
||||
val user = userByUsername(username)
|
||||
|
||||
updates.username?.let { user.username = it }
|
||||
updates.password?.let { user.password = passwordEncoder.encode(it) }
|
||||
updates.email?.let { user.email = it }
|
||||
|
||||
userRepository.save(user)
|
||||
|
||||
// If user changes password, all sessions should be invalidated
|
||||
if (updates.password != null) {
|
||||
updates.password?.let {
|
||||
user.password = passwordEncoder.encode(it)
|
||||
sessionService.logoutAllSessions()
|
||||
}
|
||||
|
||||
updates.email?.let {
|
||||
user.email = it
|
||||
user.emailConfirmed = false
|
||||
}
|
||||
|
||||
userRepository.save(user)
|
||||
}
|
||||
|
||||
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 org.springframework.content.commons.store.ContentStore
|
||||
import org.springframework.stereotype.Repository
|
||||
|
||||
@Repository
|
||||
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