WIP: Implemented Password reset flow based on user configurable templates

This commit is contained in:
grimsi
2024-09-22 21:14:34 +02:00
parent 1b01c368ea
commit d0856685f8
13 changed files with 226 additions and 40 deletions
+1
View File
@@ -48,3 +48,4 @@ out/
/src/main/frontend/generated/ /src/main/frontend/generated/
/db/ /db/
/logs/ /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 withConfigPage from "Frontend/components/administration/withConfigPage";
import * as Yup from 'yup'; import * as Yup from 'yup';
import ConfigFormField from "Frontend/components/administration/ConfigFormField"; import ConfigFormField from "Frontend/components/administration/ConfigFormField";
@@ -6,6 +6,7 @@ import Section from "Frontend/components/general/Section";
import { import {
Button, Button,
Card, Card,
Chip,
Modal, Modal,
ModalBody, ModalBody,
ModalContent, ModalContent,
@@ -14,15 +15,23 @@ import {
Textarea, Textarea,
useDisclosure useDisclosure
} from "@nextui-org/react"; } from "@nextui-org/react";
import {ConfigEndpoint, NotificationEndpoint} from "Frontend/generated/endpoints"; import {MessageTemplateEndpoint, NotificationEndpoint} from "Frontend/generated/endpoints";
import {toast} from "sonner"; import {toast} from "sonner";
import ConfigEntryDto from "Frontend/generated/de/grimsi/gameyfin/config/dto/ConfigEntryDto";
import {Pencil} from "@phosphor-icons/react"; import {Pencil} from "@phosphor-icons/react";
import MessageTemplateDto from "Frontend/generated/de/grimsi/gameyfin/notifications/templates/MessageTemplateDto";
function NotificationManagementLayout({getConfig, getConfigs, formik}: any) { function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
const {isOpen, onOpen, onOpenChange} = useDisclosure(); 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) { async function verifyCredentials(provider: string) {
const credentials: Record<string, any> = { const credentials: Record<string, any> = {
@@ -41,17 +50,22 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
} }
} }
async function openModal(template: ConfigEntryDto) { async function openModal(template: MessageTemplateDto) {
let templateContent = await ConfigEndpoint.get(template.key); setSelectedTemplate(template);
setSelectedTemplate({
...template, let templateContent = await MessageTemplateEndpoint.read(template.key);
value: templateContent
}); if (templateContent === undefined) {
toast.error("Can't read template content");
return;
}
setTemplateContent(templateContent);
onOpen(); onOpen();
} }
async function saveTemplate(template: ConfigEntryDto) { async function saveTemplate(template: MessageTemplateDto) {
await ConfigEndpoint.set(template.key, template.value); await MessageTemplateEndpoint.save(template.key, templateContent);
} }
return ( return (
@@ -81,7 +95,7 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
<div className="flex flex-col flex-1"> <div className="flex flex-col flex-1">
<Section title="Message Templates"/> <Section title="Message Templates"/>
<div className="flex flex-col gap-4"> <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}> <Card className="flex flex-row items-center gap-2 p-4" key={template.key}>
<Button isIconOnly <Button isIconOnly
size="sm" size="sm"
@@ -103,18 +117,24 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
{(onClose) => ( {(onClose) => (
<> <>
<ModalHeader <ModalHeader
className="flex flex-col gap-1">Edit {selectedTemplate?.description.toLowerCase()}</ModalHeader> className="flex flex-col gap-1">Edit {selectedTemplate?.name} Template</ModalHeader>
<ModalBody> <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 <Textarea
size="lg" size="lg"
autoFocus autoFocus
disableAutosize disableAutosize
value={selectedTemplate?.value} value={templateContent}
onChange={(e) => { onChange={(e) => {
if (selectedTemplate?.key) setSelectedTemplate({ setTemplateContent(e.target.value)
...selectedTemplate,
value: e.target.value
})
}} }}
classNames={{ classNames={{
input: "resize-y min-h-[500px]" input: "resize-y min-h-[500px]"
@@ -127,7 +147,7 @@ function NotificationManagementLayout({getConfig, getConfigs, formik}: any) {
</Button> </Button>
<Button color="primary" onPress={async () => { <Button color="primary" onPress={async () => {
if (selectedTemplate) { if (selectedTemplate) {
await saveTemplate(selectedTemplate); await saveTemplate(selectedTemplate,);
toast.success("Template saved") toast.success("Template saved")
onClose(); 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 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) class GameRequestEvent(source: Any) : ApplicationEvent(source)
@@ -54,7 +54,7 @@ class SsoAuthenticationSuccessHandler(
// Update user with new SSO data // Update user with new SSO data
matchedUser.username = oidcUser.preferredUsername matchedUser.username = oidcUser.preferredUsername
matchedUser.email = oidcUser.email matchedUser.email = oidcUser.email
matchedUser.email_confirmed = true matchedUser.emailConfirmed = true
matchedUser.oidcProviderId = oidcUser.subject matchedUser.oidcProviderId = oidcUser.subject
} }
@@ -2,17 +2,22 @@ package de.grimsi.gameyfin.notifications
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.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.KLogger
import io.github.oshai.kotlinlogging.KotlinLogging import io.github.oshai.kotlinlogging.KotlinLogging
import org.springframework.context.ApplicationContext import org.springframework.context.ApplicationContext
import org.springframework.context.event.EventListener import org.springframework.context.event.EventListener
import org.springframework.scheduling.annotation.Async import org.springframework.scheduling.annotation.Async
import org.springframework.scheduling.annotation.EnableAsync
import org.springframework.stereotype.Service import org.springframework.stereotype.Service
import java.util.* import java.util.*
@EnableAsync
@Service @Service
class NotificationService( class NotificationService(
private val applicationContext: ApplicationContext private val applicationContext: ApplicationContext,
private val templateService: MessageTemplateService
) { ) {
val log: KLogger = KotlinLogging.logger {} val log: KLogger = KotlinLogging.logger {}
@@ -40,12 +45,16 @@ 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 content = templateService.fillMessageTemplate(
MessageTemplates.PasswordResetRequest,
mapOf("username" to token.user.username, "resetLink" to resetLink)
)
// TODO: Implement proper email template
sendNotification( sendNotification(
token.user.email, token.user.email,
"Password Reset Request", "[Gameyfin] Password Reset Request",
"You have requested a password reset. Your token is ${token.token}" 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 package de.grimsi.gameyfin.users
import de.grimsi.gameyfin.core.Utils
import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent import de.grimsi.gameyfin.core.events.PasswordResetRequestEvent
import de.grimsi.gameyfin.users.entities.PasswordResetToken import de.grimsi.gameyfin.users.entities.PasswordResetToken
import de.grimsi.gameyfin.users.entities.User import de.grimsi.gameyfin.users.entities.User
@@ -29,16 +30,22 @@ class PasswordResetService(
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
get() = Utils.getBaseUrl()
fun requestPasswordReset(email: String) { 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) val user = userService.getByEmail(email)
// A user can only reset its password if its email is confirmed, and it's not an SSO user // 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) val token = createPasswordResetToken(user)
eventPublisher.publishEvent(PasswordResetRequestEvent(this, token)) eventPublisher.publishEvent(PasswordResetRequestEvent(this, token, baseUrl))
} }
// Simulate a delay to prevent timing attacks // Simulate a delay to prevent timing attacks
@@ -73,12 +80,4 @@ class PasswordResetService(
passwordResetTokenRepository.delete(passwordResetToken) passwordResetTokenRepository.delete(passwordResetToken)
sessionService.logoutAllSessions(user) 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( return UserInfoDto(
username = user.username, username = user.username,
email = user.email, email = user.email,
emailConfirmed = user.email_confirmed, emailConfirmed = user.emailConfirmed,
managedBySso = user.oidcProviderId != null, managedBySso = user.oidcProviderId != null,
roles = user.roles.map { r -> r.rolename } roles = user.roles.map { r -> r.rolename }
) )
@@ -27,7 +27,8 @@ class User(
@Convert(converter = EncryptionConverter::class) @Convert(converter = EncryptionConverter::class)
var email: String, var email: String,
var email_confirmed: Boolean = false, // TODO: Add email confirmation
var emailConfirmed: Boolean = true,
var enabled: Boolean = true, var enabled: Boolean = true,
@@ -47,7 +48,7 @@ class User(
constructor(oidcUser: OidcUser) : this( constructor(oidcUser: OidcUser) : this(
username = oidcUser.preferredUsername, username = oidcUser.preferredUsername,
email = oidcUser.email, email = oidcUser.email,
email_confirmed = true, emailConfirmed = true,
enabled = true, enabled = true,
oidcProviderId = oidcUser.subject oidcProviderId = oidcUser.subject
) )