mirror of
https://github.com/BrenBroZAYT/gameyfin.git
synced 2026-06-13 16:40:01 +00:00
WIP: Implemented Password reset flow based on user configurable templates
This commit is contained in:
@@ -48,3 +48,4 @@ out/
|
||||
/src/main/frontend/generated/
|
||||
/db/
|
||||
/logs/
|
||||
/templates/
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import React, {useState} from "react";
|
||||
import React, {useEffect, useState} from "react";
|
||||
import withConfigPage from "Frontend/components/administration/withConfigPage";
|
||||
import * as Yup from 'yup';
|
||||
import ConfigFormField from "Frontend/components/administration/ConfigFormField";
|
||||
@@ -6,6 +6,7 @@ import Section from "Frontend/components/general/Section";
|
||||
import {
|
||||
Button,
|
||||
Card,
|
||||
Chip,
|
||||
Modal,
|
||||
ModalBody,
|
||||
ModalContent,
|
||||
@@ -14,15 +15,23 @@ import {
|
||||
Textarea,
|
||||
useDisclosure
|
||||
} from "@nextui-org/react";
|
||||
import {ConfigEndpoint, NotificationEndpoint} from "Frontend/generated/endpoints";
|
||||
import {MessageTemplateEndpoint, NotificationEndpoint} from "Frontend/generated/endpoints";
|
||||
import {toast} from "sonner";
|
||||
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
|
||||
import {Pencil} from "@phosphor-icons/react";
|
||||
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
|
||||
|
||||
function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
||||
|
||||
const {isOpen, onOpen, onOpenChange} = useDisclosure();
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<ConfigEntryDto | null>(null);
|
||||
const [availableTemplates, setAvailableTemplates] = useState<MessageTemplateDto[]>([]);
|
||||
const [selectedTemplate, setSelectedTemplate] = useState<MessageTemplateDto | null>(null);
|
||||
const [templateContent, setTemplateContent] = useState<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
MessageTemplateEndpoint.getAll().then((response: any) => {
|
||||
setAvailableTemplates(response as MessageTemplateDto[]);
|
||||
});
|
||||
}, []);
|
||||
|
||||
async function verifyCredentials(provider: string) {
|
||||
const credentials: Record<string, any> = {
|
||||
@@ -41,17 +50,22 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
||||
}
|
||||
}
|
||||
|
||||
async function openModal(template: ConfigEntryDto) {
|
||||
let templateContent = await ConfigEndpoint.get(template.key);
|
||||
setSelectedTemplate({
|
||||
...template,
|
||||
value: templateContent
|
||||
});
|
||||
async function openModal(template: MessageTemplateDto) {
|
||||
setSelectedTemplate(template);
|
||||
|
||||
let templateContent = await MessageTemplateEndpoint.read(template.key);
|
||||
|
||||
if (templateContent === undefined) {
|
||||
toast.error("Can't read template content");
|
||||
return;
|
||||
}
|
||||
|
||||
setTemplateContent(templateContent);
|
||||
onOpen();
|
||||
}
|
||||
|
||||
async function saveTemplate(template: ConfigEntryDto) {
|
||||
await ConfigEndpoint.set(template.key, template.value);
|
||||
async function saveTemplate(template: MessageTemplateDto) {
|
||||
await MessageTemplateEndpoint.save(template.key, templateContent);
|
||||
}
|
||||
|
||||
return (
|
||||
@@ -81,7 +95,7 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
||||
<div className="flex flex-col flex-1">
|
||||
<Section title="Message Templates"/>
|
||||
<div className="flex flex-col gap-4">
|
||||
{getConfigs("notifications.templates").map((template: ConfigEntryDto) =>
|
||||
{availableTemplates.map((template: MessageTemplateDto) =>
|
||||
<Card className="flex flex-row items-center gap-2 p-4" key={template.key}>
|
||||
<Button isIconOnly
|
||||
size="sm"
|
||||
@@ -103,18 +117,24 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
||||
{(onClose) => (
|
||||
<>
|
||||
<ModalHeader
|
||||
className="flex flex-col gap-1">Edit {selectedTemplate?.description.toLowerCase()}</ModalHeader>
|
||||
className="flex flex-col gap-1">Edit {selectedTemplate?.name} Template</ModalHeader>
|
||||
<ModalBody>
|
||||
<div className="flex flex-row gap-2">
|
||||
<p>Available placeholders:</p>
|
||||
{selectedTemplate?.availablePlaceholders?.map((placeholder) =>
|
||||
<Chip radius="sm"
|
||||
key={placeholder}
|
||||
color={templateContent.includes(`{${placeholder as string}}`) ? "success" : "danger"}
|
||||
>{placeholder}</Chip>
|
||||
)}
|
||||
</div>
|
||||
<Textarea
|
||||
size="lg"
|
||||
autoFocus
|
||||
disableAutosize
|
||||
value={selectedTemplate?.value}
|
||||
value={templateContent}
|
||||
onChange={(e) => {
|
||||
if (selectedTemplate?.key) setSelectedTemplate({
|
||||
...selectedTemplate,
|
||||
value: e.target.value
|
||||
})
|
||||
setTemplateContent(e.target.value)
|
||||
}}
|
||||
classNames={{
|
||||
input: "resize-y min-h-[500px]"
|
||||
@@ -127,7 +147,7 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
|
||||
</Button>
|
||||
<Button color="primary" onPress={async () => {
|
||||
if (selectedTemplate) {
|
||||
await saveTemplate(selectedTemplate);
|
||||
await saveTemplate(selectedTemplate,);
|
||||
toast.success("Template saved")
|
||||
onClose();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.grimsi.gameyfin.core
|
||||
|
||||
import org.springframework.web.context.request.RequestContextHolder
|
||||
import org.springframework.web.context.request.ServletRequestAttributes
|
||||
|
||||
class Utils {
|
||||
companion object {
|
||||
fun maskEmail(email: String): String {
|
||||
val regex = """(?:\G(?!^)|(?<=^[^@]{2}|@))[^@](?!\.[^.]+$)""".toRegex()
|
||||
return email.replace(regex, "*")
|
||||
}
|
||||
|
||||
fun getBaseUrl(): String {
|
||||
val request = (RequestContextHolder.getRequestAttributes() as ServletRequestAttributes).request
|
||||
val scheme = request.scheme
|
||||
val serverName = request.serverName
|
||||
val serverPort = request.serverPort
|
||||
val contextPath = request.contextPath
|
||||
|
||||
return if (serverPort == 80 || serverPort == 443) {
|
||||
"$scheme://$serverName$contextPath"
|
||||
} else {
|
||||
"$scheme://$serverName:$serverPort$contextPath"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -7,7 +7,8 @@ class UserInvitationEvent(source: Any) : ApplicationEvent(source)
|
||||
|
||||
class UserRegistrationEvent(source: Any) : ApplicationEvent(source)
|
||||
|
||||
class PasswordResetRequestEvent(source: Any, val token: PasswordResetToken) : ApplicationEvent(source)
|
||||
class PasswordResetRequestEvent(source: Any, val token: PasswordResetToken, val baseUrl: String) :
|
||||
ApplicationEvent(source)
|
||||
|
||||
class GameRequestEvent(source: Any) : ApplicationEvent(source)
|
||||
|
||||
|
||||
@@ -54,7 +54,7 @@ class SsoAuthenticationSuccessHandler(
|
||||
// Update user with new SSO data
|
||||
matchedUser.username = oidcUser.preferredUsername
|
||||
matchedUser.email = oidcUser.email
|
||||
matchedUser.email_confirmed = true
|
||||
matchedUser.emailConfirmed = true
|
||||
matchedUser.oidcProviderId = oidcUser.subject
|
||||
}
|
||||
|
||||
|
||||
@@ -2,17 +2,22 @@ package de.grimsi.gameyfin.notifications
|
||||
|
||||
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 io.github.oshai.kotlinlogging.KLogger
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.context.ApplicationContext
|
||||
import org.springframework.context.event.EventListener
|
||||
import org.springframework.scheduling.annotation.Async
|
||||
import org.springframework.scheduling.annotation.EnableAsync
|
||||
import org.springframework.stereotype.Service
|
||||
import java.util.*
|
||||
|
||||
@EnableAsync
|
||||
@Service
|
||||
class NotificationService(
|
||||
private val applicationContext: ApplicationContext
|
||||
private val applicationContext: ApplicationContext,
|
||||
private val templateService: MessageTemplateService
|
||||
) {
|
||||
|
||||
val log: KLogger = KotlinLogging.logger {}
|
||||
@@ -40,12 +45,16 @@ class NotificationService(
|
||||
log.info { "Sending password reset request notification" }
|
||||
|
||||
val token = event.token
|
||||
val resetLink = event.baseUrl + "/reset-password?token=${token.token}"
|
||||
val content = templateService.fillMessageTemplate(
|
||||
MessageTemplates.PasswordResetRequest,
|
||||
mapOf("username" to token.user.username, "resetLink" to resetLink)
|
||||
)
|
||||
|
||||
// TODO: Implement proper email template
|
||||
sendNotification(
|
||||
token.user.email,
|
||||
"Password Reset Request",
|
||||
"You have requested a password reset. Your token is ${token.token}"
|
||||
"[Gameyfin] Password Reset Request",
|
||||
content
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
package de.grimsi.gameyfin.notifications.templates
|
||||
|
||||
data class MessageTemplateDto(
|
||||
val key: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val availablePlaceholders: List<String>
|
||||
)
|
||||
@@ -0,0 +1,27 @@
|
||||
package de.grimsi.gameyfin.notifications.templates
|
||||
|
||||
import com.vaadin.hilla.Endpoint
|
||||
import de.grimsi.gameyfin.core.Roles
|
||||
import jakarta.annotation.security.RolesAllowed
|
||||
|
||||
@RolesAllowed(Roles.Names.SUPERADMIN, Roles.Names.ADMIN)
|
||||
@Endpoint
|
||||
class MessageTemplateEndpoint(
|
||||
private val messageTemplateService: MessageTemplateService
|
||||
) {
|
||||
fun getAll(): List<MessageTemplateDto> {
|
||||
return messageTemplateService.getMessageTemplates()
|
||||
}
|
||||
|
||||
fun get(key: String): MessageTemplates {
|
||||
return messageTemplateService.getMessageTemplate(key)
|
||||
}
|
||||
|
||||
fun read(key: String): String {
|
||||
return messageTemplateService.getMessageTemplateContent(key)
|
||||
}
|
||||
|
||||
fun save(key: String, content: String) {
|
||||
messageTemplateService.setMessageTemplateContent(key, content)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
package de.grimsi.gameyfin.notifications.templates
|
||||
|
||||
import io.github.oshai.kotlinlogging.KotlinLogging
|
||||
import org.springframework.stereotype.Service
|
||||
import java.nio.file.Files
|
||||
import java.nio.file.Path
|
||||
import kotlin.io.path.readText
|
||||
import kotlin.io.path.writeText
|
||||
|
||||
@Service
|
||||
class MessageTemplateService {
|
||||
|
||||
companion object {
|
||||
private const val TEMPLATE_PATH = "templates"
|
||||
private const val TEMPLATE_EXTENSION = "html"
|
||||
}
|
||||
|
||||
private val log = KotlinLogging.logger {}
|
||||
|
||||
fun getMessageTemplates(): List<MessageTemplateDto> {
|
||||
log.info { "Getting all message templates" }
|
||||
val messageTemplates = MessageTemplates::class.sealedSubclasses.flatMap { subclass ->
|
||||
subclass.objectInstance?.let { listOf(it) } ?: listOf()
|
||||
}
|
||||
return messageTemplates.map { MessageTemplateDto(it.key, it.name, it.description, it.availablePlaceholders) }
|
||||
}
|
||||
|
||||
fun getMessageTemplate(key: String): MessageTemplates {
|
||||
return MessageTemplates::class.sealedSubclasses
|
||||
.mapNotNull { it.objectInstance }
|
||||
.find { it.key == key }
|
||||
?: throw IllegalArgumentException("Message template with key '$key' not found")
|
||||
}
|
||||
|
||||
fun getMessageTemplateContent(key: String): String {
|
||||
log.info { "Reading message template content for key '$key'" }
|
||||
return getOrCreateTemplateFile(key).readText()
|
||||
}
|
||||
|
||||
fun fillMessageTemplate(template: MessageTemplates, placeholders: Map<String, String>): String {
|
||||
if (placeholders.keys != template.availablePlaceholders.toSet()) {
|
||||
throw IllegalArgumentException("Placeholders do not match available placeholders for template '${template.key}'")
|
||||
}
|
||||
|
||||
val content = getMessageTemplateContent(template.key)
|
||||
return placeholders.entries.fold(content) { acc, (placeholder, value) ->
|
||||
acc.replace("{$placeholder}", value)
|
||||
}
|
||||
}
|
||||
|
||||
fun setMessageTemplateContent(key: String, content: String) {
|
||||
log.info { "Saving message template content for key '$key'" }
|
||||
getOrCreateTemplateFile(key).writeText(content)
|
||||
}
|
||||
|
||||
private fun getOrCreateTemplateFile(key: String): Path {
|
||||
val path = Path.of("./", TEMPLATE_PATH, "$key.$TEMPLATE_EXTENSION")
|
||||
if (Files.notExists(path)) {
|
||||
Files.createDirectories(path.parent)
|
||||
Files.createFile(path)
|
||||
}
|
||||
return path
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
package de.grimsi.gameyfin.notifications.templates
|
||||
|
||||
sealed class MessageTemplates(
|
||||
val key: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
val availablePlaceholders: List<String> = emptyList()
|
||||
) {
|
||||
data object UserInvitation : MessageTemplates(
|
||||
"user-invitation",
|
||||
"User Invitation",
|
||||
"Template for the invitation message for new users",
|
||||
listOf("invitationLink")
|
||||
)
|
||||
|
||||
data object Welcome : MessageTemplates(
|
||||
"welcome",
|
||||
"Welcome",
|
||||
"Template for the welcome message for new users",
|
||||
listOf("username")
|
||||
)
|
||||
|
||||
data object PasswordResetRequest : MessageTemplates(
|
||||
"password-reset-request",
|
||||
"Password Reset Request",
|
||||
"Template for the password reset request message",
|
||||
listOf("username", "resetLink")
|
||||
)
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
package de.grimsi.gameyfin.users
|
||||
|
||||
import de.grimsi.gameyfin.core.Utils
|
||||
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
|
||||
import de.grimsi.gameyfin.users.entities.PasswordResetToken
|
||||
import de.grimsi.gameyfin.users.entities.User
|
||||
@@ -29,16 +30,22 @@ class PasswordResetService(
|
||||
|
||||
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()
|
||||
|
||||
fun requestPasswordReset(email: String) {
|
||||
|
||||
log.info { "Initiating password reset request for '${maskEmail(email)}'" }
|
||||
log.info { "Initiating password reset request for '${Utils.maskEmail(email)}'" }
|
||||
|
||||
val user = userService.getByEmail(email)
|
||||
|
||||
// A user can only reset its password if its email is confirmed, and it's not an SSO user
|
||||
if (user != null && user.email_confirmed && user.oidcProviderId == null) {
|
||||
if (user != null && user.emailConfirmed && user.oidcProviderId == null) {
|
||||
val token = createPasswordResetToken(user)
|
||||
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token))
|
||||
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, baseUrl))
|
||||
}
|
||||
|
||||
// Simulate a delay to prevent timing attacks
|
||||
@@ -73,12 +80,4 @@ class PasswordResetService(
|
||||
passwordResetTokenRepository.delete(passwordResetToken)
|
||||
sessionService.logoutAllSessions(user)
|
||||
}
|
||||
|
||||
private fun maskEmail(email: String): String {
|
||||
val regex = """(?:\G(?!^)|(?<=^[^@]{2}|@))[^@](?!\.[^.]+$)""".toRegex()
|
||||
return email.replace(regex, "*")
|
||||
}
|
||||
|
||||
private val PasswordResetToken.isExpired: Boolean
|
||||
get() = createdOn?.plus(TOKEN_EXPIRATION.toJavaDuration())!!.isBefore(Instant.now())
|
||||
}
|
||||
@@ -150,7 +150,7 @@ class UserService(
|
||||
return UserInfoDto(
|
||||
username = user.username,
|
||||
email = user.email,
|
||||
emailConfirmed = user.email_confirmed,
|
||||
emailConfirmed = user.emailConfirmed,
|
||||
managedBySso = user.oidcProviderId != null,
|
||||
roles = user.roles.map { r -> r.rolename }
|
||||
)
|
||||
|
||||
@@ -27,7 +27,8 @@ class User(
|
||||
@Convert(converter = EncryptionConverter::class)
|
||||
var email: String,
|
||||
|
||||
var email_confirmed: Boolean = false,
|
||||
// TODO: Add email confirmation
|
||||
var emailConfirmed: Boolean = true,
|
||||
|
||||
var enabled: Boolean = true,
|
||||
|
||||
@@ -47,7 +48,7 @@ class User(
|
||||
constructor(oidcUser: OidcUser) : this(
|
||||
username = oidcUser.preferredUsername,
|
||||
email = oidcUser.email,
|
||||
email_confirmed = true,
|
||||
emailConfirmed = true,
|
||||
enabled = true,
|
||||
oidcProviderId = oidcUser.subject
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user