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/
/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
)